diff --git a/src/File/SkipPathMatcher.php b/src/File/SkipPathMatcher.php index 5914b7f3..ce122bd1 100644 --- a/src/File/SkipPathMatcher.php +++ b/src/File/SkipPathMatcher.php @@ -27,7 +27,8 @@ * An absolute path therefore matches only that absolute location; it is not * re-anchored under the base path. * - * A glob pattern is matched against both the absolute path and the base-relative path. + * A glob pattern is matched against both the absolute path and the base-relative path, + * and matching directories also skip their descendants. * * Instances are cached per (base path, skip paths) pair, ignoring skip path * order and duplicates, and memoise per-path @@ -139,11 +140,17 @@ private function computeIsSkipped(string $path): bool : $normalisedPath; foreach ($this->patterns as $pattern) { - if (fnmatch($pattern, $normalisedPath) || fnmatch($pattern, $relativePath)) { + if ($this->matchesPattern($pattern, $normalisedPath) || $this->matchesPattern($pattern, $relativePath)) { return true; } } return false; } + + private function matchesPattern(string $pattern, string $path): bool + { + // Without FNM_PATHNAME, the appended wildcard also matches nested descendants. + return fnmatch($pattern, $path) || fnmatch($pattern . '/*', $path); + } } diff --git a/tests/Analyser/AnalyserSkipPathsTest.php b/tests/Analyser/AnalyserSkipPathsTest.php index 39619df9..df1fcb2a 100644 --- a/tests/Analyser/AnalyserSkipPathsTest.php +++ b/tests/Analyser/AnalyserSkipPathsTest.php @@ -7,14 +7,49 @@ use Boundwize\StructArmed\Analyser\Analyser; use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\File\SkipPathMatcher; +use Boundwize\StructArmed\Preset\Preset; +use Boundwize\StructArmed\Preset\Presets\Psr4Preset; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; +use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use function file_put_contents; +use function mkdir; + #[CoversClass(Analyser::class)] #[CoversClass(SkipPathMatcher::class)] final class AnalyserSkipPathsTest extends TestCase { + use TemporaryDirectoryCleanupTrait; + + public function testPsr4RuleSpecificDirectoryGlobSkipsDescendants(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-psr4-skips'); + file_put_contents($basePath . '/composer.json', '{"autoload-dev":{"psr-4":{"App\\\\":"tests/"}}}'); + + foreach (['functional/Core/Nested', 'unit', 'integration', 'functional/CoreExtra'] as $directory) { + mkdir($basePath . '/tests/' . $directory, 0777, true); + file_put_contents($basePath . '/tests/' . $directory . '/Wrong.php', 'skip([ + Psr4Preset::CLASSES_MUST_MATCH_COMPOSER => [ + 'tests/**/Core', + 'tests/unit', + 'tests/integration', + ], + ]) + ->withPreset(Preset::PSR4()); + + $violations = (new Analyser($basePath))->analyse($architecture) + ->forRule(Psr4Preset::CLASSES_MUST_MATCH_COMPOSER); + + $this->assertCount(1, $violations); + $this->assertStringEndsWith('/tests/functional/CoreExtra/Wrong.php', $violations[0]->file); + } + public function testAnalyserComposesGlobalAndRuleSpecificSkipsForClassRules(): void { $architecture = Architecture::define() diff --git a/tests/File/SkipPathMatcherTest.php b/tests/File/SkipPathMatcherTest.php index 2afff9ed..bb4d18e1 100644 --- a/tests/File/SkipPathMatcherTest.php +++ b/tests/File/SkipPathMatcherTest.php @@ -59,6 +59,19 @@ public function testCompileReturnsDifferentInstanceForDifferentBasePath(): void ); } + public function testDirectoryGlobSkipsDescendantsWithoutMatchingSiblingPrefixes(): void + { + foreach (['tests/**/Core', '/project/tests/**/Core'] as $pattern) { + $matcher = SkipPathMatcher::compile('/project', [$pattern]); + + $this->assertTrue($matcher->isSkipped('/project/tests/functional/Core')); + $this->assertTrue($matcher->isSkipped('/project/tests/functional/Core/Foo.php')); + $this->assertTrue($matcher->isSkipped('/project/tests/functional/Core/Nested/Foo.php')); + $this->assertFalse($matcher->isSkipped('/project/tests/functional/CoreExtra/Foo.php')); + $this->assertFalse($matcher->isSkipped('/other/tests/functional/Core/Foo.php')); + } + } + public function testLeadingSlashSkipPathMatchesOnlyTheAbsoluteLocation(): void { $skipPathMatcher = SkipPathMatcher::compile('/project', ['/vendor']);