From dcf1b649737de29eda279ed9097b6e9ac262dccf Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 11 Sep 2026 00:32:07 +0700 Subject: [PATCH 1/2] =?UTF-8?q?Fix=20rule-specific=20glob=20skips=20to=20i?= =?UTF-8?q?nclude=20matching=20directories=E2=80=99=20descendants?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/File/SkipPathMatcher.php | 22 +++++++++++++-- tests/Analyser/AnalyserSkipPathsTest.php | 35 ++++++++++++++++++++++++ tests/File/SkipPathMatcherTest.php | 13 +++++++++ 3 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/File/SkipPathMatcher.php b/src/File/SkipPathMatcher.php index 5914b7f3..6ab99834 100644 --- a/src/File/SkipPathMatcher.php +++ b/src/File/SkipPathMatcher.php @@ -8,6 +8,7 @@ use function array_unique; use function array_values; +use function dirname; use function fnmatch; use function implode; use function realpath; @@ -27,7 +28,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, + * including their ancestors so 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 +141,27 @@ 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 + { + while (true) { + if (fnmatch($pattern, $path)) { + return true; + } + + $parent = dirname($path); + if ($parent === $path || $parent === '.') { + return false; + } + + $path = $parent; + } + } } 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']); From e641ff8b03b2563dee8e96244496f17217760af0 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 11 Sep 2026 00:39:39 +0700 Subject: [PATCH 2/2] clean up --- src/File/SkipPathMatcher.php | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/File/SkipPathMatcher.php b/src/File/SkipPathMatcher.php index 6ab99834..ce122bd1 100644 --- a/src/File/SkipPathMatcher.php +++ b/src/File/SkipPathMatcher.php @@ -8,7 +8,6 @@ use function array_unique; use function array_values; -use function dirname; use function fnmatch; use function implode; use function realpath; @@ -29,7 +28,7 @@ * re-anchored under the base path. * * A glob pattern is matched against both the absolute path and the base-relative path, - * including their ancestors so matching directories also skip their descendants. + * 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 @@ -151,17 +150,7 @@ private function computeIsSkipped(string $path): bool private function matchesPattern(string $pattern, string $path): bool { - while (true) { - if (fnmatch($pattern, $path)) { - return true; - } - - $parent = dirname($path); - if ($parent === $path || $parent === '.') { - return false; - } - - $path = $parent; - } + // Without FNM_PATHNAME, the appended wildcard also matches nested descendants. + return fnmatch($pattern, $path) || fnmatch($pattern . '/*', $path); } }