From 2ca8dd71963733b00a2368a703d29155c6077aac Mon Sep 17 00:00:00 2001 From: "Reymart A. Calicdan" Date: Sat, 22 Aug 2026 14:22:08 +0800 Subject: [PATCH] Enhance PathMatcher with canonicalizePath method and improve unit tests; update StreamWrapper documentation for clarity --- src/Internal/PathMatcher.php | 120 +++++++++++++++++++------- src/Internal/StreamWrapper.php | 27 +++--- tests/Internal/PathMatcherTest.php | 131 ++++++++++++++++++++++++++++- 3 files changed, 229 insertions(+), 49 deletions(-) diff --git a/src/Internal/PathMatcher.php b/src/Internal/PathMatcher.php index b92f913..59b95f9 100644 --- a/src/Internal/PathMatcher.php +++ b/src/Internal/PathMatcher.php @@ -5,7 +5,9 @@ namespace TypePHP\Internal; /** - * @internal Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching. + * Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching. + * + * @internal */ final class PathMatcher { @@ -41,7 +43,7 @@ final class PathMatcher private static ?string $cachedLibSrcDir = null; /** - * Resets compiled pattern and directory caches. + * Resets compiled pattern, prefix, and directory caches. */ public static function reset(): void { @@ -64,16 +66,60 @@ public static function normalizePath(string|false|null $path): string return str_replace('\\', '/', $path); } + /** + * Collapses relative directory traversals (..) into canonical paths. + * Preserves leading root slashes and root boundaries. + */ + public static function canonicalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + if (! str_contains($path, '..') && ! str_contains($path, '/.')) { + return $path; + } + + $parts = explode('/', $path); + $absolutes = []; + + foreach ($parts as $part) { + if ($part === '.' || ($part === '' && \count($absolutes) > 0)) { + continue; + } + + if ($part === '') { + $absolutes[] = ''; + + continue; + } + + if ($part === '..') { + if (\count($absolutes) > 0 && end($absolutes) !== '..' && end($absolutes) !== '') { + array_pop($absolutes); + } elseif (\count($absolutes) === 0 || end($absolutes) === '..') { + $absolutes[] = '..'; + } + } else { + $absolutes[] = $part; + } + } + + if ($absolutes === ['']) { + return '/'; + } + + return implode('/', $absolutes); + } + /** * Determines whether a given path is located within a vendor directory. */ public static function isVendorPath(string $normalizedPath, string $rawPath = ''): bool { - $normalizedRaw = self::normalizePath($rawPath); + $canon = self::canonicalizePath($normalizedPath); + $canonRaw = $rawPath !== '' ? self::canonicalizePath(self::normalizePath($rawPath)) : ''; - return str_starts_with($normalizedPath, 'vendor/') - || str_contains($normalizedPath, '/vendor/') - || ($normalizedRaw !== '' && (str_starts_with($normalizedRaw, 'vendor/') || str_contains($normalizedRaw, '/vendor/'))); + return str_starts_with($canon, 'vendor/') + || str_contains($canon, '/vendor/') + || ($canonRaw !== '' && (str_starts_with($canonRaw, 'vendor/') || str_contains($canonRaw, '/vendor/'))); } /** @@ -85,11 +131,11 @@ public static function isCachePath(string $normalizedPath): bool self::$cachedCacheDir = rtrim(self::normalizePath(CacheManager::getCacheDir()), '/') . '/'; } - return str_starts_with($normalizedPath, self::$cachedCacheDir); + return str_starts_with(self::canonicalizePath($normalizedPath), self::$cachedCacheDir); } /** - * Determines whether a path belongs to TypePHP's own internal engine source files. + * Determines whether a path belongs to TypePHP's internal engine source files. */ public static function isLibraryInternal(string $normalizedPath): bool { @@ -103,11 +149,13 @@ public static function isLibraryInternal(string $normalizedPath): bool return false; } + $canon = self::canonicalizePath($normalizedPath); + if (str_contains($libSrcDir, '/vendor/')) { - return str_starts_with($normalizedPath, $libSrcDir); + return str_starts_with($canon, $libSrcDir); } - if (str_starts_with($normalizedPath, $libSrcDir)) { + if (str_starts_with($canon, $libSrcDir)) { $internalDirs = [ $libSrcDir . 'Internal/', $libSrcDir . 'Contract/', @@ -122,7 +170,7 @@ public static function isLibraryInternal(string $normalizedPath): bool ]; foreach ($internalDirs as $dir) { - if (str_starts_with($normalizedPath, $dir)) { + if (str_starts_with($canon, $dir)) { return true; } } @@ -156,27 +204,30 @@ public static function hasIncludeMatchingPrefix(string $prefix, array $includes) } /** - * Determines whether a directory path is a dynamic writable cache/log directory. + * Determines whether a directory path is a dynamic writable cache, log, or storage 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/'); + $canon = self::canonicalizePath($normalizedPath); + + return str_contains($canon, '/var/cache/') || str_starts_with($canon, 'var/cache/') + || str_contains($canon, '/var/log/') || str_starts_with($canon, 'var/log/') + || str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/') + || str_contains($canon, '/cache/') || str_starts_with($canon, '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. + * High-speed string pre-filter to reject non-application paths before executing regex matching. */ public static function mayPathBeIncluded(string $normalizedPath): bool { - if (str_contains($normalizedPath, '/node_modules/') || str_starts_with($normalizedPath, 'node_modules/')) { + $canon = self::canonicalizePath($normalizedPath); + + if (str_contains($canon, '/node_modules/') || str_starts_with($canon, 'node_modules/')) { return false; } - if (self::isCachePath($normalizedPath)) { + if (self::isCachePath($canon)) { return false; } @@ -184,19 +235,19 @@ public static function mayPathBeIncluded(string $normalizedPath): bool /** @var array $includes */ $includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**']; - if (str_contains($normalizedPath, '/vendor/') || str_starts_with($normalizedPath, 'vendor/')) { + if (str_contains($canon, '/vendor/') || str_starts_with($canon, 'vendor/')) { if (! self::hasIncludeMatchingPrefix('vendor/', $includes)) { return false; } } - if (str_contains($normalizedPath, '/var/') || str_starts_with($normalizedPath, 'var/')) { + if (str_contains($canon, '/var/') || str_starts_with($canon, 'var/')) { if (! self::hasIncludeMatchingPrefix('var/', $includes)) { return false; } } - if (str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/')) { + if (str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/')) { if (! self::hasIncludeMatchingPrefix('storage/', $includes)) { return false; } @@ -213,14 +264,19 @@ public static function compileGlobToRegex(string $glob, string $baseDir): string $glob = self::normalizePath(trim($glob)); $isAbsolute = str_starts_with($glob, '/') || (bool) preg_match('#^[a-zA-Z]:/#', $glob); - $regex = preg_quote($glob, '#'); - $regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex); - if ($isAbsolute) { + $regex = preg_quote($glob, '#'); + $regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex); $pattern = '^' . $regex . '$'; - } elseif ($glob === '*' || $glob === '**' || str_starts_with($glob, '**')) { - $pattern = '.*' . ($glob === '*' || $glob === '**' ? '' : substr($regex, 4)) . '$'; + } elseif ($glob === '*' || $glob === '**') { + $pattern = '.*$'; + } elseif (str_starts_with($glob, '**/')) { + $subRegex = preg_quote(substr($glob, 3), '#'); + $subRegex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $subRegex); + $pattern = '(^|.*\/)' . $subRegex . '$'; } else { + $regex = preg_quote($glob, '#'); + $regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex); $pattern = '(^' . preg_quote($baseDir . '/', '#') . '|^)' . $regex . '$'; } @@ -241,7 +297,8 @@ public static function isPathIncluded( ?string $baseDir = null ): bool { $baseDir = $baseDir !== null ? self::normalizePath($baseDir) : Config::getProjectRoot(); - $normalizedRaw = self::normalizePath($rawPath); + $normalizedPath = self::canonicalizePath($normalizedPath); + $normalizedRaw = $rawPath !== '' ? self::canonicalizePath(self::normalizePath($rawPath)) : ''; $includes = self::getCompiledPatterns($includeGlobs, $baseDir, 'include'); $excludes = self::getCompiledPatterns($excludeGlobs, $baseDir, 'exclude'); @@ -250,7 +307,8 @@ public static function isPathIncluded( if ($isVendor) { $hasExplicitVendorWhitelist = false; foreach ($includes as $compiled) { - if (str_starts_with($compiled['pattern'], 'vendor/') && + if ( + str_starts_with($compiled['pattern'], 'vendor/') && (preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1)) ) { $hasExplicitVendorWhitelist = true; @@ -327,4 +385,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin return self::$compiledExcludesCache[$cacheKey] = $compiled; } -} +} \ No newline at end of file diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 0e5d96e..609cc38 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -89,6 +89,8 @@ public static function reset(): void } /** + * Registers the stream wrapper for the native 'file://' protocol. + * * @param array $config */ public static function register(array $config = []): void @@ -120,10 +122,10 @@ public static function unregister(): void /** * Transforms PHP source code by parsing AST, extracting metadata, applying ContractVisitor, and formatting output. + * Preserves exact line numbers to guarantee zero line-drift in debug stack traces. */ public static function transformSource(string $source, string $filePath = ''): string { - // Respect per-file suppression tag unless respect_ignore_tags is false if (Config::isRespectIgnoreTagsEnabled() && (str_contains($source, '@typephp-ignore-file') || str_contains($source, '@typephp-disable-file'))) { return $source; } @@ -150,14 +152,10 @@ public static function transformSource(string $source, string $filePath = ''): s /** @var array<\PhpParser\Node\Stmt> $nodesToTraverse */ $nodesToTraverse = $oldStmts; - - /** @var array<\PhpParser\Node\Stmt> $newStmts */ $newStmts = $traverser1->traverse($nodesToTraverse); $traverser2 = new NodeTraverser(); $traverser2->addVisitor(new ContractVisitor()); - - /** @var array<\PhpParser\Node\Stmt> $newStmts */ $newStmts = $traverser2->traverse($newStmts); $printer = new TypePHPPrinter(); @@ -184,13 +182,12 @@ public static function transformSource(string $source, string $filePath = ''): s $transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*/', '/*__TYPEPHP_INJECTED_END__*/ ', $transformed, $drift) ?? $transformed; } - $transformed = str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed); - - return $transformed; + return str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed); } /** - * Opens a file stream, intercepting application files for AST transformation. + * Opens a file stream, intercepting matching application files for AST transformation. + * Evaluates string fast-paths before checking file existence or unregistering the wrapper. */ public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool { @@ -395,11 +392,13 @@ public function stream_close(): void } /** - * High-speed stat resolution with $O(1)$ memoization cache. - * 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. + * High-speed stat resolution with dual-tier memoization caching: + * + * 1. Positive hit cache ($statCache): Caches stat arrays for existing files and directories. + * 2. Static negative cache ($staticNegativeStatCache): Caches false lookups strictly for static + * read-only paths (e.g. vendor directories), eliminating thousands of duplicate C-level stat calls. + * 3. Dynamic writable bypass: Never caches false for dynamic directories (var/cache, storage), + * ensuring framework cache warmers and runtime directory creation remain fully functional. * * @return array|false */ diff --git a/tests/Internal/PathMatcherTest.php b/tests/Internal/PathMatcherTest.php index ef76aa4..7c8f8f8 100644 --- a/tests/Internal/PathMatcherTest.php +++ b/tests/Internal/PathMatcherTest.php @@ -4,6 +4,7 @@ namespace TypePHP\Tests\Internal; +use ReflectionClass; use TypePHP\Internal\CacheManager; use TypePHP\Internal\Config; use TypePHP\Internal\PathMatcher; @@ -38,6 +39,33 @@ }); }); + describe('canonicalizePath()', function () { + test('returns unchanged path when no dots or traversals exist', function () { + expect(PathMatcher::canonicalizePath('src/Services/UserService.php')) + ->toBe('src/Services/UserService.php'); + }); + + test('collapses relative directory traversals (..) and current directory dots (.)', function () { + expect(PathMatcher::canonicalizePath('/var/www/vendor/composer/../doctrine/dbal/src/Schema.php')) + ->toBe('/var/www/vendor/doctrine/dbal/src/Schema.php') + ->and(PathMatcher::canonicalizePath('src/./Core/../Services/./UserService.php')) + ->toBe('src/Services/UserService.php') + ->and(PathMatcher::canonicalizePath('/app/./Controllers/../Models/User.php')) + ->toBe('/app/Models/User.php') + ; + }); + + test('handles leading root slashes and root directory boundaries', function () { + expect(PathMatcher::canonicalizePath('/../app/User.php')) + ->toBe('/app/User.php') + ->and(PathMatcher::canonicalizePath('../../../app/User.php')) + ->toBe('../../../app/User.php') + ->and(PathMatcher::canonicalizePath('/')) + ->toBe('/') + ; + }); + }); + describe('isVendorPath()', function () { test('identifies absolute and relative vendor paths correctly', function () { expect(PathMatcher::isVendorPath('vendor/doctrine/dbal/src/Schema.php'))->toBeTrue() @@ -49,6 +77,7 @@ test('identifies vendor paths when raw path has Windows backslashes', function () { expect(PathMatcher::isVendorPath('vendor/foo/bar.php', 'vendor\\foo\\bar.php'))->toBeTrue() ->and(PathMatcher::isVendorPath('C:/project/vendor/foo.php', 'C:\\project\\vendor\\foo.php'))->toBeTrue() + ->and(PathMatcher::isVendorPath('other/path.php', '/root/vendor/pkg/file.php'))->toBeTrue() ; }); @@ -79,15 +108,52 @@ $internalFile = $projectRoot . '/src/Internal/RuntimeTypeChecker.php'; $contractFile = $projectRoot . '/src/Contract/FileFilter.php'; + $commandFile = $projectRoot . '/src/Command/RunCommand.php'; + $validatorFile = $projectRoot . '/src/Validator/ArrayValidator.php'; + $wrapperFile = $projectRoot . '/src/Wrapper/CallableWrapper.php'; + $resolverFile = $projectRoot . '/src/Resolver/SpecialTypeResolver.php'; + $extensionFile = $projectRoot . '/src/Extension/ExtensionManager.php'; + $exceptionFile = $projectRoot . '/src/Exception/TypeError.php'; + $entryFile = $projectRoot . '/src/TypePHP.php'; $bootstrapFile = $projectRoot . '/src/bootstrap.php'; $mockAppFile = $projectRoot . '/src/Service.php'; + $externalFile = '/var/www/app/Models/User.php'; expect(PathMatcher::isLibraryInternal($internalFile))->toBeTrue() ->and(PathMatcher::isLibraryInternal($contractFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($commandFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($validatorFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($wrapperFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($resolverFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($extensionFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($exceptionFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($entryFile))->toBeTrue() ->and(PathMatcher::isLibraryInternal($bootstrapFile))->toBeTrue() ->and(PathMatcher::isLibraryInternal($mockAppFile))->toBeFalse() + ->and(PathMatcher::isLibraryInternal($externalFile))->toBeFalse() + ; + }); + + test('identifies internal paths when installed as a vendor dependency', function () { + $ref = new ReflectionClass(PathMatcher::class); + $prop = $ref->getProperty('cachedLibSrcDir'); + $prop->setValue(null, '/var/www/project/vendor/typephp/typephp/src/'); + + $vendorInstalledFile = '/var/www/project/vendor/typephp/typephp/src/Internal/RuntimeTypeChecker.php'; + $appFile = '/var/www/project/src/Service.php'; + + expect(PathMatcher::isLibraryInternal($vendorInstalledFile))->toBeTrue() + ->and(PathMatcher::isLibraryInternal($appFile))->toBeFalse() ; }); + + test('returns false when library directory cannot be resolved', function () { + $ref = new ReflectionClass(PathMatcher::class); + $prop = $ref->getProperty('cachedLibSrcDir'); + $prop->setValue(null, ''); + + expect(PathMatcher::isLibraryInternal('/var/www/project/src/Service.php'))->toBeFalse(); + }); }); describe('hasIncludeMatchingPrefix()', function () { @@ -106,6 +172,22 @@ expect(PathMatcher::hasIncludeMatchingPrefix('var/', $includes))->toBeTrue(); }); + + test('retrieves decision from in-memory cache on subsequent lookups', function () { + $includes = ['vendor/acme/**']; + + expect(PathMatcher::hasIncludeMatchingPrefix('vendor/', $includes))->toBeTrue() + ->and(PathMatcher::hasIncludeMatchingPrefix('vendor/', $includes))->toBeTrue() + ; + }); + + test('ignores non-string or empty include patterns gracefully', function () { + $dirtyIncludes = [null, '', 123, 'src/**']; + + expect(PathMatcher::hasIncludeMatchingPrefix('src/', $dirtyIncludes))->toBeTrue() + ->and(PathMatcher::hasIncludeMatchingPrefix('vendor/', $dirtyIncludes))->toBeFalse() + ; + }); }); describe('isDynamicWritablePath()', function () { @@ -113,9 +195,11 @@ 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('/var/www/var/log/error.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() + ->and(PathMatcher::isDynamicWritablePath('cache/opcache.php'))->toBeTrue() ; }); @@ -146,7 +230,9 @@ 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('var/logs/app.log'))->toBeFalse() ->and(PathMatcher::mayPathBeIncluded('storage/framework/views/1.php'))->toBeFalse() + ->and(PathMatcher::mayPathBeIncluded('/var/www/storage/app/file.txt'))->toBeFalse() ; } finally { Config::reset(); @@ -178,10 +264,12 @@ describe('compileGlobToRegex()', function () { test('compiles absolute glob patterns into exact anchored regex', function () { $baseDir = '/var/www/project'; - $regex = PathMatcher::compileGlobToRegex('/var/www/project/src/**', $baseDir); + $regexUnix = PathMatcher::compileGlobToRegex('/var/www/project/src/**', $baseDir); + $regexWindows = PathMatcher::compileGlobToRegex('C:/project/src/**', $baseDir); - expect(preg_match($regex, '/var/www/project/src/Service.php'))->toBe(1) - ->and(preg_match($regex, '/var/www/other/src/Service.php'))->toBe(0) + expect(preg_match($regexUnix, '/var/www/project/src/Service.php'))->toBe(1) + ->and(preg_match($regexUnix, '/var/www/other/src/Service.php'))->toBe(0) + ->and(preg_match($regexWindows, 'C:/project/src/Service.php'))->toBe(1) ; }); @@ -193,6 +281,9 @@ $singleStar = PathMatcher::compileGlobToRegex('*', $baseDir); expect(preg_match($singleStar, '/var/www/project/index.php'))->toBe(1); + + $prefixDoubleStar = PathMatcher::compileGlobToRegex('**/*.php', $baseDir); + expect(preg_match($prefixDoubleStar, '/var/www/project/app/Model.php'))->toBe(1); }); test('compiles relative globs strictly anchored to project root or relative start', function () { @@ -239,6 +330,19 @@ ; }); + test('allows explicit vendor wildcard includes', function () { + $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); + $includes = ['src/**', 'vendor/**']; + $excludes = ['vendor/doctrine/**']; + + $whitelistedVendor = $projectRoot . '/vendor/monolog/monolog/src/Logger.php'; + $excludedVendor = $projectRoot . '/vendor/doctrine/dbal/src/Column.php'; + + expect(PathMatcher::isPathIncluded($whitelistedVendor, $includes, $excludes))->toBeTrue() + ->and(PathMatcher::isPathIncluded($excludedVendor, $includes, $excludes))->toBeFalse() + ; + }); + test('allows blacklisting a specific single file inside an included directory', function () { $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); $includes = ['src/**']; @@ -261,6 +365,15 @@ expect(PathMatcher::isPathIncluded($file, $includes, $excludes))->toBeFalse(); }); + test('returns false when no include pattern matches the path', function () { + $projectRoot = PathMatcher::normalizePath(Config::getProjectRoot()); + $includes = ['src/**']; + $excludes = ['vendor/**']; + + $unmatchedFile = $projectRoot . '/bin/console'; + expect(PathMatcher::isPathIncluded($unmatchedFile, $includes, $excludes))->toBeFalse(); + }); + test('handles relative paths without leading slash cleanly', function () { $includes = ['src/**', 'app/**']; $excludes = ['vendor/**']; @@ -269,6 +382,16 @@ ->and(PathMatcher::isPathIncluded('vendor/doctrine/dbal/src/Column.php', $includes, $excludes, 'vendor/doctrine/dbal/src/Column.php'))->toBeFalse() ; }); + + test('accepts custom base directory as argument and retrieves compiled patterns from cache', function () { + $customBase = '/opt/custom/project'; + $dirtyIncludes = ['src/**', null]; + $dirtyExcludes = ['var/**', null]; + + expect(PathMatcher::isPathIncluded('/opt/custom/project/src/App.php', $dirtyIncludes, $dirtyExcludes, '', $customBase))->toBeTrue() + ->and(PathMatcher::isPathIncluded('/opt/custom/project/src/App.php', $dirtyIncludes, $dirtyExcludes, '', $customBase))->toBeTrue() + ; + }); }); describe('reset()', function () { @@ -282,4 +405,4 @@ expect(true)->toBeTrue(); }); }); -}); +}); \ No newline at end of file