From 67dacfac88c0e7e81f4b624fc8358b2934ecb25e Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 13 Oct 2026 10:05:15 +0400 Subject: [PATCH 1/7] fix(core): keep files excluded by FinderConfig out of found files A file listed in `exclude` was appended to the Symfony finder, and appended entries skip every filter, so the excluded file came back from the search, and one outside the includes was added to it. Test discovery ran tests from a file excluded in a suite's `location`. Excluded paths now only feed the filter, which also stops excluded directories from being scanned for nothing. The filter walks up from a file to its include directory; when the include is spelled unlike the real path (letter case, a symlink), it never met it and looped forever at the filesystem root. The walk now stops there and keeps the file. Assisted-By: Claude Opus 5.5 --- core/Tokenizer/Finder.php | 22 +++++---- tests/Tokenizer/Unit/FinderTest.php | 77 ++++++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/core/Tokenizer/Finder.php b/core/Tokenizer/Finder.php index c6fa511e..ac860d31 100644 --- a/core/Tokenizer/Finder.php +++ b/core/Tokenizer/Finder.php @@ -33,22 +33,20 @@ public function __construct(FinderConfig $config) $this->finder->in($iDirs); $this->finder->append($files); - # Exclude - $eDirs = $files = []; + # Exclude: excluded paths only feed the filter, they are never searched or appended + $eDirs = $eFiles = []; foreach ($config->excludes as $path) { $path->isDir() ? $eDirs[] = (string) $path - : $files[] = (string) $path; + : $eFiles[] = (string) $path; } - $this->finder->in($eDirs); - $this->finder->append($files); - $eDirs === [] && $files === [] or $this->finder->filter( - static function (\SplFileInfo $file) use ($iDirs, $eDirs, $files): bool { + $eDirs === [] && $eFiles === [] or $this->finder->filter( + static function (\SplFileInfo $file) use ($iDirs, $eDirs, $eFiles): bool { $path = Path::create($file->getRealPath())->absolute(); # Files in excluded files - if ($path->isFile() && \in_array((string) $path, $files, true)) { + if ($path->isFile() && \in_array((string) $path, $eFiles, true)) { return false; } @@ -59,7 +57,13 @@ static function (\SplFileInfo $file) use ($iDirs, $eDirs, $files): bool { return false; } - $target = \dirname($target); + # An include dir spelled unlike the real path (symlink, letter case) is never met + $parent = \dirname($target); + if ($parent === $target) { + return true; + } + + $target = $parent; } return true; diff --git a/tests/Tokenizer/Unit/FinderTest.php b/tests/Tokenizer/Unit/FinderTest.php index d16ab56b..ce778f1f 100644 --- a/tests/Tokenizer/Unit/FinderTest.php +++ b/tests/Tokenizer/Unit/FinderTest.php @@ -132,20 +132,69 @@ public function constructorWithExcludeDirectoryFiltersFilesFromIt(): void } /** - * Constructor with a single exclude file still constructs successfully and counts items. + * An excluded file inside an included directory is absent from the results. */ - public function constructorWithExcludeFileSucceeds(): void + public function excludedFileIsNotFound(): void { $stubDir = \dirname(__DIR__) . '/Stub'; - $excludedFile = $stubDir . '/EmptyClass.php'; + $excludedFile = \realpath($stubDir . '/EmptyClass.php'); - $finder = new Finder(new FinderConfig( + $finder = (new Finder(new FinderConfig( include: [$stubDir], exclude: [$excludedFile], - )); + )))->files(); - // Construction succeeds and count is accessible (the finder is usable) - Assert::true($finder->count() > 0); + $all = (new Finder(new FinderConfig(include: [$stubDir])))->files(); + Assert::false(\in_array($excludedFile, self::realPaths($finder), true)); + Assert::same($finder->count(), $all->count() - 1); + } + + /** + * An excluded file outside every included path does not join the results. + */ + public function excludedFileOutsideIncludesIsNotAdded(): void + { + $stubDir = \dirname(__DIR__) . '/Stub'; + $outsideFile = \realpath(__FILE__); + + $finder = (new Finder(new FinderConfig( + include: [$stubDir], + exclude: [$outsideFile], + )))->files(); + + Assert::false(\in_array($outsideFile, self::realPaths($finder), true)); + } + + /** + * Filters applied after construction see every entry, so an excluded file cannot slip past them. + */ + public function withFilterRejectsEverythingWhenFileExcluded(): void + { + $stubDir = \dirname(__DIR__) . '/Stub'; + + $finder = (new Finder(new FinderConfig( + include: [$stubDir], + exclude: [$stubDir . '/EmptyClass.php'], + )))->withFilter(static fn(\Symfony\Component\Finder\SplFileInfo $f): bool => false); + + Assert::same($finder->count(), 0); + } + + /** + * directories() yields no excluded file. + */ + public function directoriesSkipExcludedFile(): void + { + $stubDir = \dirname(__DIR__) . '/Stub'; + + $finder = (new Finder(new FinderConfig( + include: [$stubDir], + exclude: [$stubDir . '/EmptyClass.php'], + )))->directories(); + + foreach ($finder as $entry) { + Assert::true($entry->isDir()); + } } /** @@ -175,4 +224,18 @@ public function constructorWithExcludeDirectoryFiltersFilesInsideIt(): void // But some files should still be found Assert::true(\count($foundPaths) > 0); } + + /** + * @return list Sorted real paths of the entries. + */ + private static function realPaths(Finder $finder): array + { + $paths = []; + foreach ($finder as $file) { + $paths[] = $file->getRealPath(); + } + + \sort($paths); + return $paths; + } } From d1eca1c8ad4950f20448dcdf91c0ab790902d1cc Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 13 Oct 2026 12:21:41 +0400 Subject: [PATCH 2/7] fix(core): match FinderConfig excludes regardless of letter case and symlinks Excludes are resolved through realpath() and compared to found entries with Path::isWithin(), which ignores letter case on Windows. This replaces the string walk up the parent directories. Assisted-By: Claude Opus 5.5 --- core/Tokenizer/Finder.php | 35 ++++++--------------- tests/Tokenizer/Unit/FinderTest.php | 48 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/core/Tokenizer/Finder.php b/core/Tokenizer/Finder.php index ac860d31..7de90885 100644 --- a/core/Tokenizer/Finder.php +++ b/core/Tokenizer/Finder.php @@ -33,37 +33,20 @@ public function __construct(FinderConfig $config) $this->finder->in($iDirs); $this->finder->append($files); - # Exclude: excluded paths only feed the filter, they are never searched or appended - $eDirs = $eFiles = []; + # Exclude: excluded paths only feed the filter, they are never searched or appended. + # Found entries come as real paths, so excludes are resolved through symlinks to meet them. + $excludes = []; foreach ($config->excludes as $path) { - $path->isDir() - ? $eDirs[] = (string) $path - : $eFiles[] = (string) $path; + $excludes[] = Path::create(\realpath((string) $path) ?: $path); } - $eDirs === [] && $eFiles === [] or $this->finder->filter( - static function (\SplFileInfo $file) use ($iDirs, $eDirs, $eFiles): bool { - $path = Path::create($file->getRealPath())->absolute(); - - # Files in excluded files - if ($path->isFile() && \in_array((string) $path, $eFiles, true)) { - return false; - } - - # Directories in excluded dirs - $target = (string) $path; - while (!\in_array($target, $iDirs, true)) { - if (\in_array($target, $eDirs, true)) { + $excludes === [] or $this->finder->filter( + static function (\SplFileInfo $file) use ($excludes): bool { + $path = Path::create($file->getRealPath()); + foreach ($excludes as $excluded) { + if ($path->isWithin($excluded)) { return false; } - - # An include dir spelled unlike the real path (symlink, letter case) is never met - $parent = \dirname($target); - if ($parent === $target) { - return true; - } - - $target = $parent; } return true; diff --git a/tests/Tokenizer/Unit/FinderTest.php b/tests/Tokenizer/Unit/FinderTest.php index ce778f1f..48f3182a 100644 --- a/tests/Tokenizer/Unit/FinderTest.php +++ b/tests/Tokenizer/Unit/FinderTest.php @@ -7,6 +7,7 @@ use Testo\Application\Config\FinderConfig; use Testo\Assert; use Testo\Codecov\Covers; +use Testo\Core\Exception\SkipTest; use Testo\Test; use Testo\Tokenizer\Finder; @@ -225,6 +226,53 @@ public function constructorWithExcludeDirectoryFiltersFilesInsideIt(): void Assert::true(\count($foundPaths) > 0); } + /** + * Paths spelled in another letter case than the filesystem keeps them still take effect. + */ + public function excludesMatchPathsSpelledInOtherLetterCase(): void + { + \DIRECTORY_SEPARATOR === '/' and throw new SkipTest('Paths are case-sensitive on this OS.'); + $stubDir = \realpath(\dirname(__DIR__) . '/Stub'); + $excludedFile = $stubDir . '\EmptyClass.php'; + $excludedDirFile = $stubDir . '\NoNamespace\GlobalClass.php'; + + $finder = (new Finder(new FinderConfig( + include: [\strtolower($stubDir)], + exclude: [\strtoupper($excludedFile), \strtoupper(\dirname($excludedDirFile))], + )))->files(); + + $all = (new Finder(new FinderConfig(include: [$stubDir])))->files(); + $found = self::realPaths($finder); + Assert::false(\in_array($excludedFile, $found, true)); + Assert::false(\in_array($excludedDirFile, $found, true)); + Assert::same($finder->count(), $all->count() - 2); + } + + /** + * Paths reached through a symlink take effect, in includes and excludes alike. + */ + public function excludesMatchPathsReachedThroughSymlink(): void + { + $stubDir = \realpath(\dirname(__DIR__) . '/Stub'); + $link = \sys_get_temp_dir() . '/testo-finder-' . \bin2hex(\random_bytes(4)); + @\symlink($stubDir, $link) or throw new SkipTest('Symlinks cannot be created here.'); + + try { + $finder = (new Finder(new FinderConfig( + include: [$link], + exclude: [$link . '/EmptyClass.php', $link . '/NoNamespace'], + )))->files(); + + $all = (new Finder(new FinderConfig(include: [$stubDir])))->files(); + $found = self::realPaths($finder); + Assert::false(\in_array(\realpath($stubDir . '/EmptyClass.php'), $found, true)); + Assert::false(\in_array(\realpath($stubDir . '/NoNamespace/GlobalClass.php'), $found, true)); + Assert::same($finder->count(), $all->count() - 2); + } finally { + \DIRECTORY_SEPARATOR === '/' ? \unlink($link) : \rmdir($link); + } + } + /** * @return list Sorted real paths of the entries. */ From 93c8181bfeaf235987c5db2d07152f631a429082 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 13 Oct 2026 12:28:18 +0400 Subject: [PATCH 3/7] docs(skills): document how FinderConfig includes and excludes interact test(core): pin that a file listed in include survives an excluded directory Assisted-By: Claude Opus 5.5 --- core/Application/Config/FinderConfig.php | 6 ++++-- skills/testo-configure/SKILL.md | 2 ++ tests/Tokenizer/Unit/FinderTest.php | 16 ++++++++++++++++ 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/core/Application/Config/FinderConfig.php b/core/Application/Config/FinderConfig.php index 58946e4a..7f8156b9 100644 --- a/core/Application/Config/FinderConfig.php +++ b/core/Application/Config/FinderConfig.php @@ -24,8 +24,10 @@ public array $excludes; /** - * @param iterable $include Include directories or files to the scope - * @param iterable $exclude Exclude directories or files from the scope + * @param iterable $include Include directories or files to the scope. + * A file listed here is kept even when an exclude covers it. + * @param iterable $exclude Exclude directories or files from the scope. + * Drops everything beneath each path, even a directory above an include. * * @note Glob and regex patterns are not supported */ diff --git a/skills/testo-configure/SKILL.md b/skills/testo-configure/SKILL.md index 139c917a..40d33795 100644 --- a/skills/testo-configure/SKILL.md +++ b/skills/testo-configure/SKILL.md @@ -90,6 +90,8 @@ new FinderConfig( ); ``` +An exclude drops everything beneath it, wherever it sits: an excluded directory above an include (include `core/X`, exclude `core`) leaves nothing. A file listed in `include` by name is always kept, even under an excluded directory, which pulls one file back out of an excluded tree. + ## Multi-suite layout A typical real-world `testo.php`: diff --git a/tests/Tokenizer/Unit/FinderTest.php b/tests/Tokenizer/Unit/FinderTest.php index 48f3182a..dc0ac217 100644 --- a/tests/Tokenizer/Unit/FinderTest.php +++ b/tests/Tokenizer/Unit/FinderTest.php @@ -166,6 +166,22 @@ public function excludedFileOutsideIncludesIsNotAdded(): void Assert::false(\in_array($outsideFile, self::realPaths($finder), true)); } + /** + * A file listed in include by name is kept even under an excluded directory. + */ + public function includedFileWinsOverExcludedDirectory(): void + { + $stubDir = \dirname(__DIR__) . '/Stub'; + $file = \realpath($stubDir . '/NoNamespace/GlobalClass.php'); + + $finder = (new Finder(new FinderConfig( + include: [$stubDir, $file], + exclude: [$stubDir . '/NoNamespace'], + )))->files(); + + Assert::true(\in_array($file, self::realPaths($finder), true)); + } + /** * Filters applied after construction see every entry, so an excluded file cannot slip past them. */ From 3ec3a249021f288166386a08fa0c8bf37f879fe9 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 13 Oct 2026 13:16:15 +0400 Subject: [PATCH 4/7] test(core): check the exact directories() result for an excluded file An empty result used to pass the per-entry isDir() loop, so over-exclusion went unnoticed. Assisted-By: Claude Opus 5.5 --- tests/Tokenizer/Unit/FinderTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Tokenizer/Unit/FinderTest.php b/tests/Tokenizer/Unit/FinderTest.php index dc0ac217..b0b6d122 100644 --- a/tests/Tokenizer/Unit/FinderTest.php +++ b/tests/Tokenizer/Unit/FinderTest.php @@ -209,9 +209,9 @@ public function directoriesSkipExcludedFile(): void exclude: [$stubDir . '/EmptyClass.php'], )))->directories(); - foreach ($finder as $entry) { - Assert::true($entry->isDir()); - } + $paths = self::realPaths($finder); + Assert::true(\in_array(\realpath($stubDir . '/NoNamespace'), $paths, true)); + Assert::false(\in_array(\realpath($stubDir . '/EmptyClass.php'), $paths, true)); } /** @@ -299,7 +299,6 @@ private static function realPaths(Finder $finder): array $paths[] = $file->getRealPath(); } - \sort($paths); return $paths; } } From 8e03b083073067e269bdc8f62c7dfecfca8ed4bb Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 13 Oct 2026 15:58:38 +0400 Subject: [PATCH 5/7] fix(core): skip entries without a real path in the FinderConfig exclude filter A dangling symlink has no real path, and passing `false` on to `Path::create()` threw a TypeError whenever excludes were configured. Assisted-By: Claude Opus 5.5 --- core/Tokenizer/Finder.php | 7 ++++++- tests/Tokenizer/Unit/FinderTest.php | 30 ++++++++++++++++++++++++++++- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/core/Tokenizer/Finder.php b/core/Tokenizer/Finder.php index 7de90885..7b26035b 100644 --- a/core/Tokenizer/Finder.php +++ b/core/Tokenizer/Finder.php @@ -42,7 +42,12 @@ public function __construct(FinderConfig $config) $excludes === [] or $this->finder->filter( static function (\SplFileInfo $file) use ($excludes): bool { - $path = Path::create($file->getRealPath()); + $realPath = $file->getRealPath(); + if ($realPath === false) { + return false; + } + + $path = Path::create($realPath); foreach ($excludes as $excluded) { if ($path->isWithin($excluded)) { return false; diff --git a/tests/Tokenizer/Unit/FinderTest.php b/tests/Tokenizer/Unit/FinderTest.php index b0b6d122..7aa588d2 100644 --- a/tests/Tokenizer/Unit/FinderTest.php +++ b/tests/Tokenizer/Unit/FinderTest.php @@ -290,7 +290,35 @@ public function excludesMatchPathsReachedThroughSymlink(): void } /** - * @return list Sorted real paths of the entries. + * An entry without a real path, such as a dangling symlink, is skipped by the exclude filter. + */ + public function excludeFilterSkipsDanglingSymlink(): void + { + $dir = \sys_get_temp_dir() . '/testo-finder-' . \bin2hex(\random_bytes(4)); + \mkdir($dir); + \touch($dir . '/Kept.php'); + \touch($dir . '/Excluded.php'); + $link = $dir . '/Dangling.php'; + + try { + @\symlink($dir . '/Missing.php', $link) or throw new SkipTest('Symlinks cannot be created here.'); + + $finder = new Finder(new FinderConfig( + include: [$dir], + exclude: [$dir . '/Excluded.php'], + )); + + Assert::same(self::realPaths($finder), [\realpath($dir . '/Kept.php')]); + } finally { + @\unlink($link); + \unlink($dir . '/Kept.php'); + \unlink($dir . '/Excluded.php'); + \rmdir($dir); + } + } + + /** + * @return list Real paths of the entries. */ private static function realPaths(Finder $finder): array { From a9b3a09eaaaab27ba5c247658b062280f2943ec0 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 13 Oct 2026 16:11:22 +0400 Subject: [PATCH 6/7] perf(core): stop descending into directories excluded by FinderConfig Without pruning, Symfony walked every entry beneath an excluded directory only to filter each one out, and failed on an excluded directory it could not read. Assisted-By: Claude Opus 5.5 --- core/Tokenizer/Finder.php | 2 ++ tests/Tokenizer/Unit/FinderTest.php | 30 +++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/core/Tokenizer/Finder.php b/core/Tokenizer/Finder.php index 7b26035b..68e9decb 100644 --- a/core/Tokenizer/Finder.php +++ b/core/Tokenizer/Finder.php @@ -56,6 +56,8 @@ static function (\SplFileInfo $file) use ($excludes): bool { return true; }, + # Prune; symfony/finder 6.4 takes this flag positionally only + true, ); } diff --git a/tests/Tokenizer/Unit/FinderTest.php b/tests/Tokenizer/Unit/FinderTest.php index 7aa588d2..784de095 100644 --- a/tests/Tokenizer/Unit/FinderTest.php +++ b/tests/Tokenizer/Unit/FinderTest.php @@ -317,6 +317,36 @@ public function excludeFilterSkipsDanglingSymlink(): void } } + /** + * An excluded directory is not descended into, so an unreadable one breaks nothing. + */ + public function excludedDirectoryIsNotTraversed(): void + { + $dir = \sys_get_temp_dir() . '/testo-finder-' . \bin2hex(\random_bytes(4)); + $locked = $dir . '/Locked'; + \mkdir($locked, recursive: true); + \touch($dir . '/Kept.php'); + \touch($locked . '/Hidden.php'); + \chmod($locked, 0o000); + + try { + \is_readable($locked) and throw new SkipTest('Directory permissions are not enforced here.'); + + $finder = new Finder(new FinderConfig( + include: [$dir], + exclude: [$locked], + )); + + Assert::same(self::realPaths($finder), [\realpath($dir . '/Kept.php')]); + } finally { + \chmod($locked, 0o700); + \unlink($locked . '/Hidden.php'); + \rmdir($locked); + \unlink($dir . '/Kept.php'); + \rmdir($dir); + } + } + /** * @return list Real paths of the entries. */ From 7c46d1746d233e7884fbf9d2b15c934187d74287 Mon Sep 17 00:00:00 2001 From: roxblnfk Date: Tue, 13 Oct 2026 19:19:26 +0400 Subject: [PATCH 7/7] test(core): put the actual value first in FinderTest assertions Assisted-By: Claude Opus 5.5 --- tests/Tokenizer/Unit/FinderTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Tokenizer/Unit/FinderTest.php b/tests/Tokenizer/Unit/FinderTest.php index 784de095..b5224aad 100644 --- a/tests/Tokenizer/Unit/FinderTest.php +++ b/tests/Tokenizer/Unit/FinderTest.php @@ -81,7 +81,7 @@ public function withFilterReturnsSelfWithFilterApplied(): void Assert::true($filtered instanceof Finder); Assert::true($filtered !== $finder); - Assert::same(0, $filtered->count()); + Assert::same($filtered->count(), 0); } /** @@ -97,8 +97,8 @@ public function cloneIsIndependentFromOriginal(): void $derived = $original->withFilter(static fn(\Symfony\Component\Finder\SplFileInfo $f): bool => false); - Assert::same(0, $derived->count()); - Assert::same($originalCount, $original->count()); + Assert::same($derived->count(), 0); + Assert::same($original->count(), $originalCount); } /** @@ -112,7 +112,7 @@ public function constructorWithIncludeFilesCountsThoseFiles(): void $finder = new Finder(new FinderConfig(include: [$fileA, $fileB])); - Assert::same(2, $finder->count()); + Assert::same($finder->count(), 2); } /**