From 7d2a50ea4d0d16114290f7d80c2a9880193a5109 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Tue, 17 Feb 2026 08:52:43 +0100 Subject: [PATCH 1/5] Initial work on #6519 --- composer.json | 1 + composer.lock | 76 ++++++++++++++++++- src/TextUI/Configuration/FileFilterMapper.php | 63 +++++++++++++++ src/TextUI/Configuration/SourceFilter.php | 31 ++++---- tests/unit/TextUI/SourceFilterTest.php | 3 +- 5 files changed, 154 insertions(+), 20 deletions(-) create mode 100644 src/TextUI/Configuration/FileFilterMapper.php diff --git a/composer.json b/composer.json index 893ca3d104..56040dd73e 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "sebastian/diff": "^8.0.0", "sebastian/environment": "^9.1.0", "sebastian/exporter": "^8.0.0", + "sebastian/file-filter": "^1.0@dev", "sebastian/git-state": "^1.0", "sebastian/global-state": "^9.0.0", "sebastian/object-enumerator": "^8.0.0", diff --git a/composer.lock b/composer.lock index 65070dcbaf..38c765fb21 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4e03413523e4a9056ff6e82325cd79f2", + "content-hash": "c1f6741374ff60d8b56e56d8be91d41b", "packages": [ { "name": "myclabs/deep-copy", @@ -1102,6 +1102,76 @@ ], "time": "2026-02-06T04:44:28+00:00" }, + { + "name": "sebastian/file-filter", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/file-filter.git", + "reference": "04e4370d9877b33459fe8923df797520d63b451c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/04e4370d9877b33459fe8923df797520d63b451c", + "reference": "04e4370d9877b33459fe8923df797520d63b451c", + "shasum": "" + }, + "require": { + "php": ">=8.4" + }, + "require-dev": { + "phpunit/phpunit": "^13.0" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for filtering files", + "homepage": "https://github.com/sebastianbergmann/file-filter", + "support": { + "issues": "https://github.com/sebastianbergmann/file-filter/issues", + "security": "https://github.com/sebastianbergmann/file-filter/security/policy", + "source": "https://github.com/sebastianbergmann/file-filter/tree/main" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/file-filter", + "type": "tidelift" + } + ], + "time": "2026-02-06T13:25:28+00:00" + }, { "name": "sebastian/git-state", "version": "dev-main", @@ -1771,7 +1841,9 @@ "packages-dev": [], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "sebastian/file-filter": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { diff --git a/src/TextUI/Configuration/FileFilterMapper.php b/src/TextUI/Configuration/FileFilterMapper.php new file mode 100644 index 0000000000..65c76d3856 --- /dev/null +++ b/src/TextUI/Configuration/FileFilterMapper.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ +namespace PHPUnit\TextUI\Configuration; + +use SebastianBergmann\FileFilter\Builder as FilterBuilder; +use SebastianBergmann\FileFilter\Filter as FileFilter; + +/** + * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit + * + * @internal This class is not covered by the backward compatibility promise for PHPUnit + */ +final class FileFilterMapper +{ + public function map(Source $source): FileFilter + { + return (new FilterBuilder)->build( + $this->directories($source->includeDirectories()), + $this->files($source->includeFiles()), + $this->directories($source->excludeDirectories()), + $this->files($source->excludeFiles()), + ); + } + + /** + * @return list + */ + private function directories(FilterDirectoryCollection $directories): array + { + $result = []; + + foreach ($directories as $directory) { + $result[] = [ + 'path' => $directory->path(), + 'prefix' => $directory->prefix(), + 'suffix' => $directory->suffix(), + ]; + } + + return $result; + } + + /** + * @return list + */ + private function files(FilterFileCollection $files): array + { + $result = []; + + foreach ($files as $file) { + $result[] = $file->path(); + } + + return $result; + } +} diff --git a/src/TextUI/Configuration/SourceFilter.php b/src/TextUI/Configuration/SourceFilter.php index c967475066..62d318d831 100644 --- a/src/TextUI/Configuration/SourceFilter.php +++ b/src/TextUI/Configuration/SourceFilter.php @@ -9,6 +9,8 @@ */ namespace PHPUnit\TextUI\Configuration; +use SebastianBergmann\FileFilter\Filter; + /** * @no-named-arguments Parameter names are not covered by the backward compatibility promise for PHPUnit * @@ -17,31 +19,26 @@ final class SourceFilter { private static ?self $instance = null; - - /** - * @var array - */ - private readonly array $map; + private readonly Filter $filter; public static function instance(): self { - if (self::$instance === null) { - self::$instance = new self( - (new SourceMapper)->map( - Registry::get()->source(), - ), - ); + if (self::$instance !== null) { + return self::$instance; } + self::$instance = new self( + (new FileFilterMapper)->map( + Registry::get()->source(), + ), + ); + return self::$instance; } - /** - * @param array $map - */ - public function __construct(array $map) + public function __construct(Filter $filter) { - $this->map = $map; + $this->filter = $filter; } /** @@ -49,6 +46,6 @@ public function __construct(array $map) */ public function includes(string $path): bool { - return isset($this->map[$path]); + return $this->filter->accepts($path); } } diff --git a/tests/unit/TextUI/SourceFilterTest.php b/tests/unit/TextUI/SourceFilterTest.php index 0840ced781..65d77baa9a 100644 --- a/tests/unit/TextUI/SourceFilterTest.php +++ b/tests/unit/TextUI/SourceFilterTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\Small; #[CoversClass(SourceFilter::class)] +#[CoversClass(FileFilterMapper::class)] #[Small] #[Group('textui')] #[Group('textui/configuration')] @@ -561,7 +562,7 @@ public function testDeterminesWhetherFileIsIncluded(array $expectations, Source $this->assertFileExists($file); $this->assertSame( $shouldInclude, - new SourceFilter((new SourceMapper)->map($source))->includes($file), + new SourceFilter((new FileFilterMapper)->map($source))->includes($file), sprintf('expected match to return %s for: %s', json_encode($shouldInclude), $file), ); } From b3a3280fa438fc6e34dbfd2a0e625ed730982f44 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 8 Mar 2026 07:40:28 +0100 Subject: [PATCH 2/5] Do not exclude files in hidden directories --- composer.lock | 8 ++++---- src/TextUI/Configuration/FileFilterMapper.php | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 38c765fb21..a872bbc94c 100644 --- a/composer.lock +++ b/composer.lock @@ -1108,12 +1108,12 @@ "source": { "type": "git", "url": "https://github.com/sebastianbergmann/file-filter.git", - "reference": "04e4370d9877b33459fe8923df797520d63b451c" + "reference": "cc3543bef801fb6e039ce3d2c31c7d8499f43da4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/04e4370d9877b33459fe8923df797520d63b451c", - "reference": "04e4370d9877b33459fe8923df797520d63b451c", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/cc3543bef801fb6e039ce3d2c31c7d8499f43da4", + "reference": "cc3543bef801fb6e039ce3d2c31c7d8499f43da4", "shasum": "" }, "require": { @@ -1170,7 +1170,7 @@ "type": "tidelift" } ], - "time": "2026-02-06T13:25:28+00:00" + "time": "2026-03-08T06:39:52+00:00" }, { "name": "sebastian/git-state", diff --git a/src/TextUI/Configuration/FileFilterMapper.php b/src/TextUI/Configuration/FileFilterMapper.php index 65c76d3856..fe76e9a18f 100644 --- a/src/TextUI/Configuration/FileFilterMapper.php +++ b/src/TextUI/Configuration/FileFilterMapper.php @@ -26,6 +26,7 @@ public function map(Source $source): FileFilter $this->files($source->includeFiles()), $this->directories($source->excludeDirectories()), $this->files($source->excludeFiles()), + false, ); } From e274cb8bec262bf518acc3d7d24d3af501b4d768 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 8 Mar 2026 08:30:19 +0100 Subject: [PATCH 3/5] Revert "Do not exclude files in hidden directories" This reverts commit 3d17fda1294c69411c6ddd359a11e01e062ce4ed. --- composer.lock | 8 ++++---- src/TextUI/Configuration/FileFilterMapper.php | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/composer.lock b/composer.lock index a872bbc94c..486f91a0f2 100644 --- a/composer.lock +++ b/composer.lock @@ -1108,12 +1108,12 @@ "source": { "type": "git", "url": "https://github.com/sebastianbergmann/file-filter.git", - "reference": "cc3543bef801fb6e039ce3d2c31c7d8499f43da4" + "reference": "8ce2998bec022bc562600e45f9eb3efc675a4687" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/cc3543bef801fb6e039ce3d2c31c7d8499f43da4", - "reference": "cc3543bef801fb6e039ce3d2c31c7d8499f43da4", + "url": "https://api.github.com/repos/sebastianbergmann/file-filter/zipball/8ce2998bec022bc562600e45f9eb3efc675a4687", + "reference": "8ce2998bec022bc562600e45f9eb3efc675a4687", "shasum": "" }, "require": { @@ -1170,7 +1170,7 @@ "type": "tidelift" } ], - "time": "2026-03-08T06:39:52+00:00" + "time": "2026-03-08T07:29:22+00:00" }, { "name": "sebastian/git-state", diff --git a/src/TextUI/Configuration/FileFilterMapper.php b/src/TextUI/Configuration/FileFilterMapper.php index fe76e9a18f..65c76d3856 100644 --- a/src/TextUI/Configuration/FileFilterMapper.php +++ b/src/TextUI/Configuration/FileFilterMapper.php @@ -26,7 +26,6 @@ public function map(Source $source): FileFilter $this->files($source->includeFiles()), $this->directories($source->excludeDirectories()), $this->files($source->excludeFiles()), - false, ); } From 3584d21c7a157d0405c29897536a5035f00264dd Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 8 Mar 2026 08:40:30 +0100 Subject: [PATCH 4/5] Also package sebastian/file-filter --- build.xml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build.xml b/build.xml index 644d1129c4..e0d754a49a 100644 --- a/build.xml +++ b/build.xml @@ -233,6 +233,13 @@ + + + + + + + From c9821a4ed4a1cecdd29c654f6cb93b6b433c5541 Mon Sep 17 00:00:00 2001 From: Sebastian Bergmann Date: Sun, 8 Mar 2026 13:27:10 +0100 Subject: [PATCH 5/5] Canonicalize source filter paths to resolve relative segments When phpunit.xml is in a subdirectory and uses relative paths with ".." (e.g. ../src/), the path was passed to the file filter without canonicalization, causing the generated regex to include literal ".." segments that never match resolved file paths. Apply realpath() in FileFilterMapper to normalize directory and file paths before building the filter. --- src/TextUI/Configuration/FileFilterMapper.php | 9 ++- tests/unit/TextUI/SourceFilterTest.php | 56 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) diff --git a/src/TextUI/Configuration/FileFilterMapper.php b/src/TextUI/Configuration/FileFilterMapper.php index 65c76d3856..20e0be4d4f 100644 --- a/src/TextUI/Configuration/FileFilterMapper.php +++ b/src/TextUI/Configuration/FileFilterMapper.php @@ -9,6 +9,7 @@ */ namespace PHPUnit\TextUI\Configuration; +use function realpath; use SebastianBergmann\FileFilter\Builder as FilterBuilder; use SebastianBergmann\FileFilter\Filter as FileFilter; @@ -37,8 +38,10 @@ private function directories(FilterDirectoryCollection $directories): array $result = []; foreach ($directories as $directory) { + $path = realpath($directory->path()); + $result[] = [ - 'path' => $directory->path(), + 'path' => $path !== false ? $path : $directory->path(), 'prefix' => $directory->prefix(), 'suffix' => $directory->suffix(), ]; @@ -55,7 +58,9 @@ private function files(FilterFileCollection $files): array $result = []; foreach ($files as $file) { - $result[] = $file->path(); + $path = realpath($file->path()); + + $result[] = $path !== false ? $path : $file->path(); } return $result; diff --git a/tests/unit/TextUI/SourceFilterTest.php b/tests/unit/TextUI/SourceFilterTest.php index 65d77baa9a..64832dc79b 100644 --- a/tests/unit/TextUI/SourceFilterTest.php +++ b/tests/unit/TextUI/SourceFilterTest.php @@ -514,6 +514,62 @@ public static function provider(): array ), ), ], + 'file included using directory with non-canonical path' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => true, + ], + self::createSource( + includeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath('/b/../a'), '', '.php'), + ], + ), + ), + ], + 'file included using file with non-canonical path' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => true, + ], + self::createSource(includeFiles: FilterFileCollection::fromArray( + [ + new FilterFile(self::fixturePath('/b/../a/PrefixSuffix.php')), + ], + )), + ], + 'file excluded using directory with non-canonical path' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => false, + ], + self::createSource( + includeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath(), '', '.php'), + ], + ), + excludeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath('/b/../a'), '', '.php'), + ], + ), + ), + ], + 'file excluded using file with non-canonical path' => [ + [ + self::fixturePath('a/PrefixSuffix.php') => false, + ], + self::createSource( + includeDirectories: FilterDirectoryCollection::fromArray( + [ + new FilterDirectory(self::fixturePath(), '', '.php'), + ], + ), + excludeFiles: FilterFileCollection::fromArray( + [ + new FilterFile(self::fixturePath('/b/../a/PrefixSuffix.php')), + ], + ), + ), + ], 'files included using same directory and different prefixes' => [ [ self::fixturePath('a/c/Suffix.php') => true,