From 8af748e168cad06fd3514c5fafe9b77164d453e8 Mon Sep 17 00:00:00 2001 From: Navid Kashani Date: Fri, 17 Apr 2026 12:35:39 +0330 Subject: [PATCH] Anchor built-in directory exclude patterns to path-segment boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven of the directory patterns in Config::BUILT_IN_EXCLUDE_PATTERNS are unanchored, so they behave as substring matches rather than path-segment matches. Most visibly, /ext\//i matches Text/Foo.php because "ext/" is a substring of "Text/" (case-insensitive), causing FileCopier::shouldExclude() to return true and silently drop every file under a Text/ directory from the scoped output. Anchor the affected patterns with (?:^|\/) so they only match at path-start or immediately after a /. Patterns fixed: .github/, .gitlab/, examples?/, ext/, php4/, tests?/, dev-bin/ The \b-anchored siblings (/\bbin\//i, /\bdocs?\//i) are intentionally left alone — \b has subtly different semantics at non-word characters, and tweaking them is outside this PR's scope. The loose filename patterns (CHANGELOG, Dockerfile, phpcs.xml, …) are also left alone because the substring matching is intentional there. Add tests/Unit/Config/BuiltInExcludePatternsTest.php covering each fixed pattern against canonical positives (ext/Foo.php, src/ext/Foo.php) and the collision negatives (Text/Foo.php, Context/Foo.php, MyExamples/, graphql4/, UnitTests/, my-dev-bin/, …). Pattern strings are read from BUILT_IN_EXCLUDE_PATTERNS via reflection and run directly through preg_match. Uses @dataProvider docblock + static provider so the test runs cleanly on both PHPUnit 9.6 and 10.x. Update the two ext/ / examples?/ literal assertions in ConfigTest to the new anchored form. This is a tightening of the pattern: only the substring collisions above are affected, and they were being dropped unintentionally. No public API change. --- CHANGELOG.md | 7 ++ src/Config/Config.php | 14 +-- .../Config/BuiltInExcludePatternsTest.php | 95 +++++++++++++++++++ tests/Unit/Config/ConfigTest.php | 4 +- 4 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 tests/Unit/Config/BuiltInExcludePatternsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 89cf178..d9f4063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +### Fixed +- **Unanchored built-in directory exclude patterns dropped legitimate directories**: `ext/`, `examples?/`, `tests?/`, `php4/`, `dev-bin/`, `.github/`, `.gitlab/` behaved as substring matches, so e.g. `Text/` and `Context/` were treated as `ext/` and silently omitted from the scoped output. The seven patterns now anchor to path-start or immediately after `/`. + +--- + ## 1.2.5 - 2026-04-07 ### Fixed diff --git a/src/Config/Config.php b/src/Config/Config.php index 0b39331..e2e3a77 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -24,14 +24,14 @@ class Config '/\\.phpunit/i', '/\\.editorconfig$/', '/\\.gitignore$/', - '/\\.github\\//i', - '/\\.gitlab\\//i', - '/examples?\\//i', - '/ext\\//i', - '/php4\\//i', - '/tests?\\//i', + '/(?:^|\\/)\\.github\\//i', + '/(?:^|\\/)\\.gitlab\\//i', + '/(?:^|\\/)examples?\\//i', + '/(?:^|\\/)ext\\//i', + '/(?:^|\\/)php4\\//i', + '/(?:^|\\/)tests?\\//i', '/\\bbin\\//i', - '/dev-bin\\//i', + '/(?:^|\\/)dev-bin\\//i', '/Makefile$/', '/phpunit\\.xml(\\.dist)?$/i', '/\\.travis\\.yml$/', diff --git a/tests/Unit/Config/BuiltInExcludePatternsTest.php b/tests/Unit/Config/BuiltInExcludePatternsTest.php new file mode 100644 index 0000000..b858411 --- /dev/null +++ b/tests/Unit/Config/BuiltInExcludePatternsTest.php @@ -0,0 +1,95 @@ + 'Test\\Deps', + 'packages' => [], + ], '/tmp')->getExcludePatterns(); + + foreach ( + [ + self::P_GITHUB, + self::P_GITLAB, + self::P_EXAMPLES, + self::P_EXT, + self::P_PHP4, + self::P_TESTS, + self::P_DEV_BIN, + ] as $pattern + ) { + $this->assertContains($pattern, $patterns); + } + } + + /** + * @dataProvider directoryPatternMatchProvider + */ + public function testBuiltInDirectoryPatternMatchesExpectedPaths( + string $pattern, + string $relativePath, + bool $shouldMatch + ): void { + $this->assertSame($shouldMatch, preg_match($pattern, $relativePath) === 1); + } + + /** + * @return array + */ + public static function directoryPatternMatchProvider(): array + { + return [ + '.github at root matches' => [self::P_GITHUB, '.github/workflows/ci.yml', true], + '.github nested matches' => [self::P_GITHUB, 'src/.github/foo.php', true], + 'notgithub/ does not match' => [self::P_GITHUB, 'notgithub/foo.php', false], + + '.gitlab at root matches' => [self::P_GITLAB, '.gitlab/ci.yml', true], + '.gitlab nested matches' => [self::P_GITLAB, 'src/.gitlab/foo.php', true], + 'notgitlab/ does not match' => [self::P_GITLAB, 'notgitlab/foo.php', false], + + 'example/ at root matches' => [self::P_EXAMPLES, 'example/foo.php', true], + 'examples/ at root matches' => [self::P_EXAMPLES, 'examples/foo.php', true], + 'examples/ nested matches' => [self::P_EXAMPLES, 'src/examples/foo.php', true], + 'MyExamples/ does not match' => [self::P_EXAMPLES, 'MyExamples/foo.php', false], + 'bad-examples/ does not match' => [self::P_EXAMPLES, 'bad-examples/foo.php', false], + 'nested MyExamples/ does not match' => [self::P_EXAMPLES, 'src/MyExamples/foo.php', false], + + 'ext/ at root matches' => [self::P_EXT, 'ext/Foo.php', true], + 'ext/ nested matches' => [self::P_EXT, 'src/ext/Foo.php', true], + 'Text/ does not match' => [self::P_EXT, 'Text/Foo.php', false], + 'Context/ does not match' => [self::P_EXT, 'Context/Foo.php', false], + 'nested Text/ does not match' => [self::P_EXT, 'src/Text/Foo.php', false], + + 'php4/ at root matches' => [self::P_PHP4, 'php4/foo.php', true], + 'php4/ nested matches' => [self::P_PHP4, 'src/php4/foo.php', true], + 'graphql4/ does not match' => [self::P_PHP4, 'graphql4/foo.php', false], + + 'test/ at root matches' => [self::P_TESTS, 'test/foo.php', true], + 'tests/ at root matches' => [self::P_TESTS, 'tests/foo.php', true], + 'tests/ nested matches' => [self::P_TESTS, 'src/tests/foo.php', true], + 'UnitTests/ does not match' => [self::P_TESTS, 'UnitTests/foo.php', false], + 'apitest/ does not match' => [self::P_TESTS, 'apitest/foo.php', false], + + 'dev-bin/ at root matches' => [self::P_DEV_BIN, 'dev-bin/foo.php', true], + 'dev-bin/ nested matches' => [self::P_DEV_BIN, 'src/dev-bin/foo.php', true], + 'my-dev-bin/ does not match' => [self::P_DEV_BIN, 'my-dev-bin/foo.php', false], + ]; + } +} diff --git a/tests/Unit/Config/ConfigTest.php b/tests/Unit/Config/ConfigTest.php index 71b6822..7da22b2 100644 --- a/tests/Unit/Config/ConfigTest.php +++ b/tests/Unit/Config/ConfigTest.php @@ -65,8 +65,8 @@ public function testDefaultValues(): void $this->assertSame([], $config->getExcludePackages()); // Built-in patterns are always included $this->assertContains('/\\.md$/i', $config->getExcludePatterns()); - $this->assertContains('/examples?\\//i', $config->getExcludePatterns()); - $this->assertContains('/ext\\//i', $config->getExcludePatterns()); + $this->assertContains('/(?:^|\\/)examples?\\//i', $config->getExcludePatterns()); + $this->assertContains('/(?:^|\\/)ext\\//i', $config->getExcludePatterns()); $this->assertSame(['views', 'templates', 'resources'], $config->getExcludeDirectories()); $this->assertFalse($config->shouldDeleteVendorPackages()); $this->assertTrue($config->shouldUpdateCallSites());