From 6ab3ea125ba5fea456f96c447d867641bdf8dfe0 Mon Sep 17 00:00:00 2001 From: PrinsFrank <25006490+PrinsFrank@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:30:16 +0200 Subject: [PATCH 01/14] Add method to classmap to detect ambiguous namespaces --- src/ClassMap.php | 48 ++++++++++++++++++++++++++++++++++++++++++ tests/ClassMapTest.php | 29 +++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 tests/ClassMapTest.php diff --git a/src/ClassMap.php b/src/ClassMap.php index f4391d2..067781d 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -105,6 +105,54 @@ public function getAmbiguousClasses($duplicatesFilter = '{/(test|fixture|example return $ambiguousClasses; } + /** + * A list of lists of ambiguous namespaces + * + * This occurs when the same namespace can be found in several folders + * + * As namespaces are case-insensitive but file systems can be case folding, + * Namespaces that work on a case-sensitive filesystem might not work on a case insensitive one. + * For example: a namespace Foo/Bar and Foo/bar can co-exist according to PSR4 (namespaces should be case-insensitive) + * but will break on filesystems that perform case folding. Both namespaces now co-exist in one folder, + * and one of the namespaces will not have the same casing as the folder name + * + * @return list> + */ + public function getAmbiguousNamespaces(): array + { + $visitedNamespaces = $ambiguousNamespaces = []; + foreach (array_keys($this->map) as $symbol) { + $parts = explode('\\', $symbol); + array_pop($parts); + + $currentPath = ''; + foreach ($parts as $namespace) { + $currentPath .= ($currentPath !== '' ? '\\' : '') . $namespace; + $lowerPath = strtolower($currentPath); + if (!isset($visitedNamespaces[$lowerPath])) { + $visitedNamespaces[$lowerPath] = $currentPath; + continue; + } + + if ($visitedNamespaces[$lowerPath] === $currentPath) { + continue; + } + + if (!isset($ambiguousNamespaces[$lowerPath])) { + $ambiguousNamespaces[$lowerPath][] = $visitedNamespaces[$lowerPath]; + } + + if (in_array($currentPath, $ambiguousNamespaces[$lowerPath], true)) { + continue; + } + + $ambiguousNamespaces[$lowerPath][] = $currentPath; + } + } + + return array_values($ambiguousNamespaces); + } + /** * Sorts the class map alphabetically by class names */ diff --git a/tests/ClassMapTest.php b/tests/ClassMapTest.php new file mode 100644 index 0000000..6d28f53 --- /dev/null +++ b/tests/ClassMapTest.php @@ -0,0 +1,29 @@ +addClass('Foo\casefolding\Foo', './casefolding'); + + /** @phpstan-ignore argument.type */ + $classMap->addClass('Foo\Casefolding\Bar', './Casefolding'); + + /** @phpstan-ignore argument.type */ + $classMap->addClass('Foo\Casefolding\Boop', './Casefolding'); + + self::assertSame( + [ + ['Foo\casefolding', 'Foo\Casefolding'], + ], + $classMap->getAmbiguousNamespaces() + ); + } +} From c7f7675557ed6ed6ed9971fafba1cddbe38f7c8e Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Sat, 1 Aug 2026 23:36:04 +0200 Subject: [PATCH 02/14] Detect ambiguous folders by path instead of by namespace getAmbiguousNamespaces() compared namespace strings and never looked at the paths the ClassMap already holds, so it reported namespaces which only differ in casing even when they live in folders that do not fold into each other. That fires on aws/aws-sdk-php, which uses the Aws namespace, together with its required aws/aws-crt-php dependency, which uses AWS. Their folders are aws-sdk-php/src and aws-crt-php/src/AWS, so nothing collides on any filesystem, yet every project depending on the AWS SDK would have been warned about it, and with composer/composer#13007 dump-autoload --strict-ambiguous would have exited 2. Fold path prefixes rather than namespace prefixes, which reports nothing on the aws tree while still catching the case this is meant to catch. Renamed to getAmbiguousFolders() as it now returns the paths involved, which also lets the consumer point at the folders to rename. Comparing paths additionally catches files that only differ in casing (Foo\Bar and Foo\bar are the same class to PHP, and neither getAmbiguousClasses() nor the old method saw those) and PSR-0 underscore pseudo-namespaces, which never contained a backslash to split on. Also: - stop at the topmost difference in a path, one renamed folder no longer gets reported once per namespace below it - sort the output, it followed Symfony Finder traversal order and so varied between machines - accept a $duplicatesFilter like getAmbiguousClasses() does, so ambiguity in dependencies you do not control can be filtered out. This is only possible now that paths are what gets compared - fold ASCII only, strtolower() is locale dependent before PHP 8.2 and this library supports 7.2+ Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MZmJZgxdTKD4zekKJDrmi9 --- README.md | 5 ++ src/ClassMap.php | 104 ++++++++++++++------- tests/ClassMapTest.php | 199 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 264 insertions(+), 44 deletions(-) diff --git a/README.md b/README.md index fe5ab82..ae0fa1a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,11 @@ foreach ($classMap->getMap() as $symbol => $path) { foreach ($classMap->getAmbiguousClasses() as $symbol => $paths) { // warn user about ambiguous class resolution } + +foreach ($classMap->getAmbiguousFolders() as $paths) { + // warn user that these paths only differ in casing, and therefore fold into a + // single file/folder on case insensitive filesystems like those of Windows/macOS +} ``` diff --git a/src/ClassMap.php b/src/ClassMap.php index 067781d..bd1d726 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -106,51 +106,93 @@ public function getAmbiguousClasses($duplicatesFilter = '{/(test|fixture|example } /** - * A list of lists of ambiguous namespaces + * A list of sets of paths which are identical except for their casing * - * This occurs when the same namespace can be found in several folders + * Case-insensitive filesystems (Windows, macOS) fold each such set into a single file or + * directory, so a project which autoloads correctly on a case-sensitive filesystem can behave + * differently there. This typically happens when a folder is renamed to a different casing: + * on a case-sensitive checkout src/Foo and src/foo then co-exist, while elsewhere they merge + * and the classes below them no longer match the casing of the folder they end up in. * - * As namespaces are case-insensitive but file systems can be case folding, - * Namespaces that work on a case-sensitive filesystem might not work on a case insensitive one. - * For example: a namespace Foo/Bar and Foo/bar can co-exist according to PSR4 (namespaces should be case-insensitive) - * but will break on filesystems that perform case folding. Both namespaces now co-exist in one folder, - * and one of the namespaces will not have the same casing as the folder name + * Only the topmost difference is reported for any given file, as renaming that folder also + * resolves every path below it. * - * @return list> + * By default, paths that contain test(s), fixture(s), example(s) or stub(s) are ignored + * as those are typically not problematic when they're dummy classes in the tests folder. + * If you want to get these back as well you can pass false to $duplicatesFilter. Or + * you can pass your own pattern to exclude if you need to change the default. + * + * @param non-empty-string|false $duplicatesFilter + * + * @return list> */ - public function getAmbiguousNamespaces(): array + public function getAmbiguousFolders($duplicatesFilter = '{/(test|fixture|example|stub)s?/}i'): array { - $visitedNamespaces = $ambiguousNamespaces = []; - foreach (array_keys($this->map) as $symbol) { - $parts = explode('\\', $symbol); - array_pop($parts); - - $currentPath = ''; - foreach ($parts as $namespace) { - $currentPath .= ($currentPath !== '' ? '\\' : '') . $namespace; - $lowerPath = strtolower($currentPath); - if (!isset($visitedNamespaces[$lowerPath])) { - $visitedNamespaces[$lowerPath] = $currentPath; - continue; - } + if (true === $duplicatesFilter) { + throw new \InvalidArgumentException('$duplicatesFilter should be false or a string with a valid regex, got true.'); + } - if ($visitedNamespaces[$lowerPath] === $currentPath) { - continue; - } + /** @var array $visitedPaths */ + $visitedPaths = []; + /** @var array> $ambiguousPaths */ + $ambiguousPaths = []; + foreach ($this->map as $path) { + $path = strtr($path, '\\', '/'); + if (false !== $duplicatesFilter && Preg::isMatch($duplicatesFilter, $path)) { + continue; + } - if (!isset($ambiguousNamespaces[$lowerPath])) { - $ambiguousNamespaces[$lowerPath][] = $visitedNamespaces[$lowerPath]; + // walk every prefix of the path, the full path included so that files which only + // differ in casing are caught as well as the folders above them + $offset = 0; + while (true) { + $separator = strpos($path, '/', $offset); + if (false === $separator) { + $prefix = $path; + } else { + $prefix = substr($path, 0, $separator); + $offset = $separator + 1; } - if (in_array($currentPath, $ambiguousNamespaces[$lowerPath], true)) { - continue; + if ('' !== $prefix) { + $foldedPrefix = self::foldCase($prefix); + if (!isset($visitedPaths[$foldedPrefix])) { + $visitedPaths[$foldedPrefix] = $prefix; + } elseif ($visitedPaths[$foldedPrefix] !== $prefix) { + if (!isset($ambiguousPaths[$foldedPrefix])) { + $ambiguousPaths[$foldedPrefix] = [$visitedPaths[$foldedPrefix]]; + } + if (!\in_array($prefix, $ambiguousPaths[$foldedPrefix], true)) { + $ambiguousPaths[$foldedPrefix][] = $prefix; + } + + // renaming this folder also resolves everything below it, so stop here + break; + } } - $ambiguousNamespaces[$lowerPath][] = $currentPath; + if (false === $separator) { + break; + } } } - return array_values($ambiguousNamespaces); + // sort to keep the output stable, as $this->map follows filesystem traversal order + ksort($ambiguousPaths, SORT_STRING); + foreach ($ambiguousPaths as $foldedPrefix => $paths) { + sort($paths, SORT_STRING); + $ambiguousPaths[$foldedPrefix] = $paths; + } + + return array_values($ambiguousPaths); + } + + /** + * Lowercases ASCII characters only, as strtolower is locale dependent before PHP 8.2 + */ + private static function foldCase(string $str): string + { + return strtr($str, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); } /** diff --git a/tests/ClassMapTest.php b/tests/ClassMapTest.php index 6d28f53..4313ba2 100644 --- a/tests/ClassMapTest.php +++ b/tests/ClassMapTest.php @@ -1,29 +1,202 @@ + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Composer\ClassMapGenerator; use PHPUnit\Framework\TestCase; class ClassMapTest extends TestCase { - public function testAmbiguousNamespace(): void + /** + * @dataProvider provideAmbiguousFolders + * + * @param array $classes + * @param list> $expected + */ + public function testGetAmbiguousFolders(array $classes, array $expected): void { - $classMap = new ClassMap(); - - /** @phpstan-ignore argument.type */ - $classMap->addClass('Foo\casefolding\Foo', './casefolding'); + self::assertSame($expected, self::classMapOf($classes)->getAmbiguousFolders(false)); + } - /** @phpstan-ignore argument.type */ - $classMap->addClass('Foo\Casefolding\Bar', './Casefolding'); + /** + * @return array, list>}> + */ + public static function provideAmbiguousFolders(): array + { + return [ + 'empty map' => [ + [], + [], + ], + 'no ambiguity' => [ + [ + 'Foo\Bar' => '/proj/src/Foo/Bar.php', + 'Foo\Baz' => '/proj/src/Foo/Baz.php', + ], + [], + ], + // the case this is all about: a folder renamed to a different casing, which merges + // into a single folder once checked out on a case insensitive filesystem + 'folder differing in casing' => [ + [ + 'foo\Foo' => '/proj/src/foo/Foo.php', + 'Foo\Bar' => '/proj/src/Foo/Bar.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ], + ], + 'three variants of the same folder' => [ + [ + 'A\A' => '/proj/src/Casefolding/A.php', + 'B\B' => '/proj/src/casefolding/B.php', + 'C\C' => '/proj/src/CaseFolding/C.php', + ], + [ + ['/proj/src/CaseFolding', '/proj/src/Casefolding', '/proj/src/casefolding'], + ], + ], + // only the topmost difference is reported, renaming it resolves the ones below it + 'nested differences are reported once' => [ + [ + 'A\B' => '/proj/src/Foo/Bar/A.php', + 'A\C' => '/proj/src/foo/bar/C.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ], + ], + // Foo\Bar and Foo\bar are the same class as far as PHP is concerned, and the two files + // cannot co-exist on a case insensitive filesystem either + 'files differing in casing' => [ + [ + 'Foo\Bar' => '/proj/src/Bar.php', + 'Foo\bar' => '/proj/src/bar.php', + ], + [ + ['/proj/src/Bar.php', '/proj/src/bar.php'], + ], + ], + 'unrelated folders in separate trees' => [ + [ + 'A\A' => '/proj/src/Foo/A.php', + 'B\B' => '/proj/lib/foo/B.php', + ], + [], + ], + // a namespace legitimately spread over several folders is not a problem + 'namespace spanning several folders' => [ + [ + 'Foo\A' => '/proj/src/A.php', + 'Foo\B' => '/proj/lib/B.php', + ], + [], + ], + // aws/aws-sdk-php uses the Aws namespace while its aws/aws-crt-php dependency uses AWS, + // but they live in folders which do not fold into each other + 'namespaces differing in casing in separate folders' => [ + [ + 'AWS\CRT\CRT' => '/proj/vendor/aws/aws-crt-php/src/AWS/CRT/CRT.php', + 'Aws\Sdk' => '/proj/vendor/aws/aws-sdk-php/src/Sdk.php', + ], + [], + ], + 'windows directory separators' => [ + [ + 'A\A' => 'C:\\proj\\src\\Foo\\A.php', + 'B\B' => 'C:\\proj\\src\\foo\\B.php', + ], + [ + ['C:/proj/src/Foo', 'C:/proj/src/foo'], + ], + ], + 'relative paths' => [ + [ + 'A\A' => 'src/Foo/A.php', + 'B\B' => 'src/foo/B.php', + ], + [ + ['src/Foo', 'src/foo'], + ], + ], + 'several ambiguous folders' => [ + [ + 'A\A' => '/proj/src/Zed/A.php', + 'B\B' => '/proj/src/zed/B.php', + 'C\C' => '/proj/src/Abc/C.php', + 'D\D' => '/proj/src/abc/D.php', + ], + [ + ['/proj/src/Abc', '/proj/src/abc'], + ['/proj/src/Zed', '/proj/src/zed'], + ], + ], + ]; + } - /** @phpstan-ignore argument.type */ - $classMap->addClass('Foo\Casefolding\Boop', './Casefolding'); + public function testGetAmbiguousFoldersIsNotAffectedByInsertionOrder(): void + { + $classes = [ + 'A\A' => '/proj/src/Casefolding/A.php', + 'B\B' => '/proj/src/casefolding/B.php', + ]; self::assertSame( - [ - ['Foo\casefolding', 'Foo\Casefolding'], - ], - $classMap->getAmbiguousNamespaces() + self::classMapOf($classes)->getAmbiguousFolders(false), + self::classMapOf(array_reverse($classes, true))->getAmbiguousFolders(false) ); } + + public function testGetAmbiguousFoldersFiltersTestPathsByDefault(): void + { + $classMap = self::classMapOf([ + 'A\A' => '/proj/tests/Foo/A.php', + 'B\B' => '/proj/tests/foo/B.php', + ]); + + self::assertSame([], $classMap->getAmbiguousFolders()); + self::assertSame([['/proj/tests/Foo', '/proj/tests/foo']], $classMap->getAmbiguousFolders(false)); + } + + public function testGetAmbiguousFoldersAcceptsACustomFilter(): void + { + $classMap = self::classMapOf([ + 'A\A' => '/proj/generated/Foo/A.php', + 'B\B' => '/proj/generated/foo/B.php', + ]); + + self::assertSame([], $classMap->getAmbiguousFolders('{/generated/}')); + self::assertSame([['/proj/generated/Foo', '/proj/generated/foo']], $classMap->getAmbiguousFolders()); + } + + public function testGetAmbiguousFoldersRejectsTrueAsFilter(): void + { + self::expectException(\InvalidArgumentException::class); + + // @phpstan-ignore argument.type + self::classMapOf([])->getAmbiguousFolders(true); + } + + /** + * @param array $classes + */ + private static function classMapOf(array $classes): ClassMap + { + $classMap = new ClassMap; + foreach ($classes as $class => $path) { + /** @phpstan-ignore argument.type */ + $classMap->addClass($class, $path); + } + + return $classMap; + } } From 40636e854092f3c060a6d22b8ea6f588a87d436e Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:05:04 +0200 Subject: [PATCH 03/14] Add failing tests for ambiguous folder detection Covers the problems found in review: order dependent results, paths of ambiguous classes being ignored, nested differences being hidden behind the topmost one, drive letters and stream wrapper schemes being compared case sensitively, the duplicates filter hiding conflicts with production folders, and the default filter not matching relative paths. --- tests/ClassMapTest.php | 208 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 200 insertions(+), 8 deletions(-) diff --git a/tests/ClassMapTest.php b/tests/ClassMapTest.php index 4313ba2..8ea7efe 100644 --- a/tests/ClassMapTest.php +++ b/tests/ClassMapTest.php @@ -65,14 +65,57 @@ public static function provideAmbiguousFolders(): array ['/proj/src/CaseFolding', '/proj/src/Casefolding', '/proj/src/casefolding'], ], ], - // only the topmost difference is reported, renaming it resolves the ones below it - 'nested differences are reported once' => [ + // every level whose own name differs in casing is reported, as renaming the parent + // does not make the children match each other + 'nested differences are all reported' => [ [ 'A\B' => '/proj/src/Foo/Bar/A.php', 'A\C' => '/proj/src/foo/bar/C.php', ], [ ['/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/Foo/Bar', '/proj/src/foo/bar'], + ], + ], + // a difference inherited from the parent is not repeated for children matching each other + 'ancestor difference is not repeated for identical children' => [ + [ + 'A\B' => '/proj/src/Foo/Bar/A.php', + 'A\C' => '/proj/src/foo/Bar/B.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ], + ], + 'ancestor difference is not repeated for identical files' => [ + [ + 'A\A' => '/proj/src/Foo/A.php', + 'B\A' => '/proj/src/foo/A.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ], + ], + 'files differing in casing under folders differing in casing' => [ + [ + 'A\A' => '/proj/src/Foo/A.php', + 'B\B' => '/proj/src/foo/a.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/Foo/A.php', '/proj/src/foo/a.php'], + ], + ], + 'three variants under differently cased parents' => [ + [ + 'A\A' => '/proj/src/Foo/Bar/A.php', + 'B\B' => '/proj/src/foo/bar/B.php', + 'C\C' => '/proj/src/FOO/BAR/C.php', + 'D\D' => '/proj/src/Foo/bar/D.php', + ], + [ + ['/proj/src/FOO', '/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/FOO/BAR', '/proj/src/Foo/Bar', '/proj/src/Foo/bar'], ], ], // Foo\Bar and Foo\bar are the same class as far as PHP is concerned, and the two files @@ -86,6 +129,13 @@ public static function provideAmbiguousFolders(): array ['/proj/src/Bar.php', '/proj/src/bar.php'], ], ], + 'folder and file with the same name' => [ + [ + 'A\A' => '/proj/src/Foo/A.php', + 'B\B' => '/proj/src/foo.php', + ], + [], + ], 'unrelated folders in separate trees' => [ [ 'A\A' => '/proj/src/Foo/A.php', @@ -119,6 +169,46 @@ public static function provideAmbiguousFolders(): array ['C:/proj/src/Foo', 'C:/proj/src/foo'], ], ], + 'mixed directory separators' => [ + [ + 'A\A' => 'C:\\proj\\src\\Foo\\A.php', + 'B\B' => 'C:/proj/src/foo/B.php', + ], + [ + ['C:/proj/src/Foo', 'C:/proj/src/foo'], + ], + ], + // drive letters and stream wrapper schemes are never case sensitive + 'drive letter casing is ignored' => [ + [ + 'A\A' => 'C:\\proj\\src\\A.php', + 'B\B' => 'c:\\proj\\src\\B.php', + ], + [], + ], + 'drive letter casing is kept in reported paths' => [ + [ + 'A\A' => 'C:\\proj\\src\\Foo\\A.php', + 'B\B' => 'c:\\proj\\src\\foo\\B.php', + ], + [ + ['C:/proj/src/Foo', 'c:/proj/src/foo'], + ], + ], + 'stream wrapper scheme casing is ignored' => [ + [ + 'A\A' => 'phar:///proj/lib.phar/src/A.php', + 'B\B' => 'PHAR:///proj/lib.phar/src/B.php', + ], + [], + ], + 'drive letter under a stream wrapper' => [ + [ + 'A\A' => 'phar://C:/proj/lib.phar/src/A.php', + 'B\B' => 'phar://c:/proj/lib.phar/src/B.php', + ], + [], + ], 'relative paths' => [ [ 'A\A' => 'src/Foo/A.php', @@ -146,14 +236,45 @@ public static function provideAmbiguousFolders(): array public function testGetAmbiguousFoldersIsNotAffectedByInsertionOrder(): void { $classes = [ - 'A\A' => '/proj/src/Casefolding/A.php', - 'B\B' => '/proj/src/casefolding/B.php', + 'A\A' => '/proj/src/Foo/Bar/A.php', + 'B\B' => '/proj/src/foo/B.php', + 'C\C' => '/proj/src/Foo/bar/C.php', + 'D\D' => '/proj/src/foo/Bar/D.php', + ]; + // src/foo/Bar only differs from src/Foo/Bar by its parent so it is not listed + $expected = [ + ['/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/Foo/Bar', '/proj/src/Foo/bar'], ]; - self::assertSame( - self::classMapOf($classes)->getAmbiguousFolders(false), - self::classMapOf(array_reverse($classes, true))->getAmbiguousFolders(false) + foreach (self::permutations(array_keys($classes)) as $order) { + $permutation = []; + foreach ($order as $class) { + $permutation[$class] = $classes[$class]; + } + + self::assertSame($expected, self::classMapOf($permutation)->getAmbiguousFolders(false), 'Insertion order: '.implode(', ', $order)); + } + } + + public function testGetAmbiguousFoldersIncludesPathsOfAmbiguousClasses(): void + { + // a folder renamed to a different casing with stale copies left behind: the same classes + // are found in both, so the second copy only ends up in the ambiguous classes + $classMap = self::classMapOf( + [ + 'Foo\A' => '/proj/src/Foo/A.php', + 'Foo\B' => '/proj/src/Foo/B.php', + ], + [ + 'Foo\A' => ['/proj/src/foo/A.php'], + 'Foo\B' => ['/proj/src/foo/B.php'], + ] ); + + $expected = [['/proj/src/Foo', '/proj/src/foo']]; + self::assertSame($expected, $classMap->getAmbiguousFolders()); + self::assertSame($expected, $classMap->getAmbiguousFolders(false)); } public function testGetAmbiguousFoldersFiltersTestPathsByDefault(): void @@ -167,6 +288,46 @@ public function testGetAmbiguousFoldersFiltersTestPathsByDefault(): void self::assertSame([['/proj/tests/Foo', '/proj/tests/foo']], $classMap->getAmbiguousFolders(false)); } + public function testGetAmbiguousFoldersOnlyFiltersFoldersWithoutUnfilteredPaths(): void + { + // a test folder which merges with a production folder is still a problem for the latter + $classMap = self::classMapOf([ + 'A\A' => '/proj/src/Foo/A.php', + 'B\B' => '/proj/src/foo/Tests/B.php', + 'C\C' => '/proj/src/foo/tests/C.php', + ]); + + self::assertSame([['/proj/src/Foo', '/proj/src/foo']], $classMap->getAmbiguousFolders()); + self::assertSame([ + ['/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/foo/Tests', '/proj/src/foo/tests'], + ], $classMap->getAmbiguousFolders(false)); + + // while folders only containing filtered paths are ignored altogether + $classMap = self::classMapOf([ + 'A\A' => '/proj/tests/Foo/A.php', + 'B\B' => '/proj/Tests/foo/B.php', + ]); + + self::assertSame([], $classMap->getAmbiguousFolders()); + } + + public function testDefaultDuplicatesFilterAppliesToRelativePaths(): void + { + $classMap = self::classMapOf([ + 'A\A' => 'tests/Foo/A.php', + 'B\B' => 'tests/foo/B.php', + ]); + + self::assertSame([], $classMap->getAmbiguousFolders()); + self::assertSame([['tests/Foo', 'tests/foo']], $classMap->getAmbiguousFolders(false)); + + $classMap = self::classMapOf(['A' => 'src/A.php'], ['A' => ['tests/A.php']]); + + self::assertSame([], $classMap->getAmbiguousClasses()); + self::assertSame(['A' => ['tests/A.php']], $classMap->getAmbiguousClasses(false)); + } + public function testGetAmbiguousFoldersAcceptsACustomFilter(): void { $classMap = self::classMapOf([ @@ -188,15 +349,46 @@ public function testGetAmbiguousFoldersRejectsTrueAsFilter(): void /** * @param array $classes + * @param array> $ambiguousClasses */ - private static function classMapOf(array $classes): ClassMap + private static function classMapOf(array $classes, array $ambiguousClasses = []): ClassMap { $classMap = new ClassMap; foreach ($classes as $class => $path) { /** @phpstan-ignore argument.type */ $classMap->addClass($class, $path); } + foreach ($ambiguousClasses as $class => $paths) { + foreach ($paths as $path) { + /** @phpstan-ignore argument.type */ + $classMap->addAmbiguousClass($class, $path); + } + } return $classMap; } + + /** + * @template T + * @param list $items + * @return list> + */ + private static function permutations(array $items): array + { + if (\count($items) <= 1) { + return [$items]; + } + + $permutations = []; + foreach ($items as $i => $item) { + $rest = $items; + unset($rest[$i]); + foreach (self::permutations(array_values($rest)) as $permutation) { + array_unshift($permutation, $item); + $permutations[] = $permutation; + } + } + + return $permutations; + } } From b027ed3b27f488086a3cc61b5ca66fdc6f491053 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:06:00 +0200 Subject: [PATCH 04/14] Anchor the default duplicates filter so it also matches relative paths The regex required a slash before the folder name, so a top-level tests folder in a relative path was never ignored. The default is now shared between getAmbiguousClasses and getAmbiguousFolders. --- src/ClassMap.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index bd1d726..634d0aa 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -19,6 +19,8 @@ */ class ClassMap implements \Countable { + private const DEFAULT_DUPLICATES_FILTER = '{(?:^|/)(test|fixture|example|stub)s?/}i'; + /** * @var array */ @@ -82,7 +84,7 @@ public function getPsrViolations(): array * * @return array> */ - public function getAmbiguousClasses($duplicatesFilter = '{/(test|fixture|example|stub)s?/}i'): array + public function getAmbiguousClasses($duplicatesFilter = self::DEFAULT_DUPLICATES_FILTER): array { if (false === $duplicatesFilter) { return $this->ambiguousClasses; @@ -126,7 +128,7 @@ public function getAmbiguousClasses($duplicatesFilter = '{/(test|fixture|example * * @return list> */ - public function getAmbiguousFolders($duplicatesFilter = '{/(test|fixture|example|stub)s?/}i'): array + public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES_FILTER): array { if (true === $duplicatesFilter) { throw new \InvalidArgumentException('$duplicatesFilter should be false or a string with a valid regex, got true.'); From 36dde1e0d6397b3cf7b81d3771f56c0fa60c37f3 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:06:33 +0200 Subject: [PATCH 05/14] Fold each path once instead of once per prefix ASCII folding preserves offsets, so the folded prefixes can be cut from the folded path in lockstep with the original. --- src/ClassMap.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index 634d0aa..39d3472 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -146,18 +146,20 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES // walk every prefix of the path, the full path included so that files which only // differ in casing are caught as well as the folders above them + $foldedPath = self::foldCase($path); $offset = 0; while (true) { $separator = strpos($path, '/', $offset); if (false === $separator) { $prefix = $path; + $foldedPrefix = $foldedPath; } else { $prefix = substr($path, 0, $separator); + $foldedPrefix = substr($foldedPath, 0, $separator); $offset = $separator + 1; } if ('' !== $prefix) { - $foldedPrefix = self::foldCase($prefix); if (!isset($visitedPaths[$foldedPrefix])) { $visitedPaths[$foldedPrefix] = $prefix; } elseif ($visitedPaths[$foldedPrefix] !== $prefix) { From 6bc9be20b27899cc9981cd3feea54ba8daa777a7 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:07:27 +0200 Subject: [PATCH 06/14] Walk the paths in a sorted order when detecting ambiguous folders The map follows filesystem traversal order, which is not stable across machines, and the first prefix seen for a folder decided what was reported for every later one. --- src/ClassMap.php | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index 39d3472..eca365b 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -138,8 +138,14 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES $visitedPaths = []; /** @var array> $ambiguousPaths */ $ambiguousPaths = []; + $paths = []; foreach ($this->map as $path) { - $path = strtr($path, '\\', '/'); + $paths[] = strtr($path, '\\', '/'); + } + // sort so that the result does not depend on the order in which files were scanned + sort($paths, SORT_STRING); + + foreach ($paths as $path) { if (false !== $duplicatesFilter && Preg::isMatch($duplicatesFilter, $path)) { continue; } @@ -181,11 +187,11 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES } } - // sort to keep the output stable, as $this->map follows filesystem traversal order + // sort the groups by folded path, as they are created whenever a second variant is seen ksort($ambiguousPaths, SORT_STRING); - foreach ($ambiguousPaths as $foldedPrefix => $paths) { - sort($paths, SORT_STRING); - $ambiguousPaths[$foldedPrefix] = $paths; + foreach ($ambiguousPaths as $foldedPrefix => $group) { + sort($group, SORT_STRING); + $ambiguousPaths[$foldedPrefix] = $group; } return array_values($ambiguousPaths); From dc1fc3dc29e64bdb3c2ff3ac7e56e11bcd145be1 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:10:44 +0200 Subject: [PATCH 07/14] Include the paths of ambiguous classes in ambiguous folder detection A folder renamed to a different casing with stale copies left behind defines the same classes twice, so the second copy of every file only ended up in the ambiguous classes and the folders were never compared. --- src/ClassMap.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/ClassMap.php b/src/ClassMap.php index eca365b..eccf69a 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -142,6 +142,13 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES foreach ($this->map as $path) { $paths[] = strtr($path, '\\', '/'); } + // a class found in several files only has one of them in the map, but the others + // are just as relevant here, and typically are the renamed folder's stale copies + foreach ($this->ambiguousClasses as $duplicatePaths) { + foreach ($duplicatePaths as $path) { + $paths[] = strtr($path, '\\', '/'); + } + } // sort so that the result does not depend on the order in which files were scanned sort($paths, SORT_STRING); From e983044c4094671a5a59ebdfc1cb537bf36ef27d Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:11:21 +0200 Subject: [PATCH 08/14] Report every casing difference instead of only the topmost one Renaming the topmost folder does not make its children match each other, so src/Foo/Bar and src/foo/bar still collide after src/foo is renamed and the user would just get a new warning. Prefixes are now grouped by folded path and by the casing of their own name, which reports every level that differs by itself while not repeating a parent's difference for children written the same way. --- src/ClassMap.php | 55 +++++++++++++++++++----------------------------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index eccf69a..3567b4f 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -116,8 +116,8 @@ public function getAmbiguousClasses($duplicatesFilter = self::DEFAULT_DUPLICATES * on a case-sensitive checkout src/Foo and src/foo then co-exist, while elsewhere they merge * and the classes below them no longer match the casing of the folder they end up in. * - * Only the topmost difference is reported for any given file, as renaming that folder also - * resolves every path below it. + * Every folder or file whose own name differs in casing is reported, while differences it + * only inherits from a parent folder are reported once at that parent's level. * * By default, paths that contain test(s), fixture(s), example(s) or stub(s) are ignored * as those are typically not problematic when they're dummy classes in the tests folder. @@ -134,10 +134,6 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES throw new \InvalidArgumentException('$duplicatesFilter should be false or a string with a valid regex, got true.'); } - /** @var array $visitedPaths */ - $visitedPaths = []; - /** @var array> $ambiguousPaths */ - $ambiguousPaths = []; $paths = []; foreach ($this->map as $path) { $paths[] = strtr($path, '\\', '/'); @@ -152,6 +148,9 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES // sort so that the result does not depend on the order in which files were scanned sort($paths, SORT_STRING); + // folded prefix => name of its last segment as written => first path seen with that name + /** @var array> $seen */ + $seen = []; foreach ($paths as $path) { if (false !== $duplicatesFilter && Preg::isMatch($duplicatesFilter, $path)) { continue; @@ -163,43 +162,33 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES $offset = 0; while (true) { $separator = strpos($path, '/', $offset); - if (false === $separator) { - $prefix = $path; - $foldedPrefix = $foldedPath; - } else { - $prefix = substr($path, 0, $separator); - $foldedPrefix = substr($foldedPath, 0, $separator); - $offset = $separator + 1; - } - - if ('' !== $prefix) { - if (!isset($visitedPaths[$foldedPrefix])) { - $visitedPaths[$foldedPrefix] = $prefix; - } elseif ($visitedPaths[$foldedPrefix] !== $prefix) { - if (!isset($ambiguousPaths[$foldedPrefix])) { - $ambiguousPaths[$foldedPrefix] = [$visitedPaths[$foldedPrefix]]; - } - if (!\in_array($prefix, $ambiguousPaths[$foldedPrefix], true)) { - $ambiguousPaths[$foldedPrefix][] = $prefix; - } - - // renaming this folder also resolves everything below it, so stop here - break; + $end = false === $separator ? \strlen($path) : $separator; + $name = substr($path, $offset, $end - $offset); + // a name written the same way as one already seen only differs by its parents, + // which get reported at their own level + if ('' !== $name) { + $foldedPrefix = substr($foldedPath, 0, $end); + if (!isset($seen[$foldedPrefix][$name])) { + $seen[$foldedPrefix][$name] = substr($path, 0, $offset).$name; } } if (false === $separator) { break; } + $offset = $separator + 1; } } - // sort the groups by folded path, as they are created whenever a second variant is seen - ksort($ambiguousPaths, SORT_STRING); - foreach ($ambiguousPaths as $foldedPrefix => $group) { - sort($group, SORT_STRING); - $ambiguousPaths[$foldedPrefix] = $group; + $ambiguousPaths = []; + foreach ($seen as $foldedPrefix => $variants) { + if (\count($variants) > 1) { + // already sorted as the paths were walked in order + $ambiguousPaths[$foldedPrefix] = array_values($variants); + } } + // sort the groups by folded path, as they got created whenever a second variant was seen + ksort($ambiguousPaths, SORT_STRING); return array_values($ambiguousPaths); } From dbe9cc468f07013d0c6c37ebf44f1ed97cd00401 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:12:11 +0200 Subject: [PATCH 09/14] Ignore the casing of drive letters and stream wrapper schemes Absolute paths keep the casing they were given, so two autoload rules using C:/ and c:/ ended up reported as ambiguous although drive letters and stream wrapper schemes are never case sensitive. --- src/ClassMap.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index 3567b4f..eb5d088 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -159,7 +159,9 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES // walk every prefix of the path, the full path included so that files which only // differ in casing are caught as well as the folders above them $foldedPath = self::foldCase($path); - $offset = 0; + // drive letters and stream wrapper schemes are never case sensitive so they are + // skipped, see also ClassMapGenerator::normalizePath which matches the same prefixes + $offset = Preg::isMatchStrictGroups('{^(?:[0-9a-z]{2,}+:(?://(?:[a-z]:)?)?|[a-z]:)}i', $path, $match) ? \strlen($match[0]) : 0; while (true) { $separator = strpos($path, '/', $offset); $end = false === $separator ? \strlen($path) : $separator; From cba32e9c0b37fd6f9e4bbe069af2261f1e46f4ba Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:12:34 +0200 Subject: [PATCH 10/14] Apply the duplicates filter per set of paths instead of per path Skipping filtered paths before comparing them hid a test folder merging with a production folder, which is still a problem for the latter. Sets are now left out only when every path below them is filtered. --- src/ClassMap.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index eb5d088..bfeffee 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -124,6 +124,9 @@ public function getAmbiguousClasses($duplicatesFilter = self::DEFAULT_DUPLICATES * If you want to get these back as well you can pass false to $duplicatesFilter. Or * you can pass your own pattern to exclude if you need to change the default. * + * A set of paths is only left out when every path below it is ignored though, as a test + * folder merging with a production folder is still a problem for the latter. + * * @param non-empty-string|false $duplicatesFilter * * @return list> @@ -151,10 +154,11 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES // folded prefix => name of its last segment as written => first path seen with that name /** @var array> $seen */ $seen = []; + // folded prefixes with at least one path below them which is not filtered out + /** @var array $relevant */ + $relevant = []; foreach ($paths as $path) { - if (false !== $duplicatesFilter && Preg::isMatch($duplicatesFilter, $path)) { - continue; - } + $filtered = false !== $duplicatesFilter && Preg::isMatch($duplicatesFilter, $path); // walk every prefix of the path, the full path included so that files which only // differ in casing are caught as well as the folders above them @@ -173,6 +177,9 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES if (!isset($seen[$foldedPrefix][$name])) { $seen[$foldedPrefix][$name] = substr($path, 0, $offset).$name; } + if (!$filtered) { + $relevant[$foldedPrefix] = true; + } } if (false === $separator) { @@ -184,7 +191,7 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES $ambiguousPaths = []; foreach ($seen as $foldedPrefix => $variants) { - if (\count($variants) > 1) { + if (\count($variants) > 1 && isset($relevant[$foldedPrefix])) { // already sorted as the paths were walked in order $ambiguousPaths[$foldedPrefix] = array_values($variants); } From c6d67166bc123b72002ac15eeb76e5380013f5e2 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 11:13:38 +0200 Subject: [PATCH 11/14] Share the duplicates filter validation between both ambiguity methods --- src/ClassMap.php | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index bfeffee..01db296 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -90,9 +90,7 @@ public function getAmbiguousClasses($duplicatesFilter = self::DEFAULT_DUPLICATES return $this->ambiguousClasses; } - if (true === $duplicatesFilter) { - throw new \InvalidArgumentException('$duplicatesFilter should be false or a string with a valid regex, got true.'); - } + self::assertValidDuplicatesFilter($duplicatesFilter); $ambiguousClasses = []; foreach ($this->ambiguousClasses as $class => $paths) { @@ -133,9 +131,7 @@ public function getAmbiguousClasses($duplicatesFilter = self::DEFAULT_DUPLICATES */ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES_FILTER): array { - if (true === $duplicatesFilter) { - throw new \InvalidArgumentException('$duplicatesFilter should be false or a string with a valid regex, got true.'); - } + self::assertValidDuplicatesFilter($duplicatesFilter); $paths = []; foreach ($this->map as $path) { @@ -202,6 +198,16 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES return array_values($ambiguousPaths); } + /** + * @param non-empty-string|bool $duplicatesFilter + */ + private static function assertValidDuplicatesFilter($duplicatesFilter): void + { + if (true === $duplicatesFilter) { + throw new \InvalidArgumentException('$duplicatesFilter should be false or a string with a valid regex, got true.'); + } + } + /** * Lowercases ASCII characters only, as strtolower is locale dependent before PHP 8.2 */ From 39b056a3653acc223e61ea7c765b93adb84a5fb0 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 14:03:54 +0200 Subject: [PATCH 12/14] Split paths with explode when detecting ambiguous folders substr returns false instead of an empty string on PHP < 8 when the offset reaches the end of the string, which made PHPStan lose the non-empty-string type of the reported paths on PHP 7.2. --- src/ClassMap.php | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index 01db296..237b9c8 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -161,14 +161,13 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES $foldedPath = self::foldCase($path); // drive letters and stream wrapper schemes are never case sensitive so they are // skipped, see also ClassMapGenerator::normalizePath which matches the same prefixes - $offset = Preg::isMatchStrictGroups('{^(?:[0-9a-z]{2,}+:(?://(?:[a-z]:)?)?|[a-z]:)}i', $path, $match) ? \strlen($match[0]) : 0; - while (true) { - $separator = strpos($path, '/', $offset); - $end = false === $separator ? \strlen($path) : $separator; - $name = substr($path, $offset, $end - $offset); + $rootLength = Preg::isMatchStrictGroups('{^(?:[0-9a-z]{2,}+:(?://(?:[a-z]:)?)?|[a-z]:)}i', $path, $match) ? \strlen($match[0]) : 0; + $offset = 0; + foreach (explode('/', $path) as $name) { + $end = $offset + \strlen($name); // a name written the same way as one already seen only differs by its parents, // which get reported at their own level - if ('' !== $name) { + if ('' !== $name && $end > $rootLength) { $foldedPrefix = substr($foldedPath, 0, $end); if (!isset($seen[$foldedPrefix][$name])) { $seen[$foldedPrefix][$name] = substr($path, 0, $offset).$name; @@ -177,11 +176,7 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES $relevant[$foldedPrefix] = true; } } - - if (false === $separator) { - break; - } - $offset = $separator + 1; + $offset = $end + 1; } } From cd49703e5aba352c995170cf9c6df45c5af8d0f8 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 16:46:07 +0200 Subject: [PATCH 13/14] Speed up ambiguous folder detection for the common case of finding nothing The sorted paths let each one skip the folders it shares with the previous path, only the first casing seen per folder is stored until a second one shows up, and the duplicates filter is only evaluated once something was found. --- src/ClassMap.php | 63 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/src/ClassMap.php b/src/ClassMap.php index 237b9c8..09892f1 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -144,17 +144,21 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES $paths[] = strtr($path, '\\', '/'); } } - // sort so that the result does not depend on the order in which files were scanned + // sorted so the result does not depend on scan order, and so that each path can skip + // the folders it shares with the previous one as those were already registered sort($paths, SORT_STRING); + // folded prefix => first path seen for it, which has the lowest casing as paths are sorted + /** @var array $first */ + $first = []; // folded prefix => name of its last segment as written => first path seen with that name - /** @var array> $seen */ - $seen = []; - // folded prefixes with at least one path below them which is not filtered out - /** @var array $relevant */ - $relevant = []; + /** @var array> $variants */ + $variants = []; + $previous = ''; foreach ($paths as $path) { - $filtered = false !== $duplicatesFilter && Preg::isMatch($duplicatesFilter, $path); + $common = strspn($path ^ $previous, "\0"); + $skipUntil = $common > 0 ? (int) strrpos($path, '/', $common - \strlen($path) - 1) : 0; + $previous = $path; // walk every prefix of the path, the full path included so that files which only // differ in casing are caught as well as the folders above them @@ -165,29 +169,50 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES $offset = 0; foreach (explode('/', $path) as $name) { $end = $offset + \strlen($name); - // a name written the same way as one already seen only differs by its parents, - // which get reported at their own level - if ('' !== $name && $end > $rootLength) { + if ('' !== $name && $end > $rootLength && $end > $skipUntil) { $foldedPrefix = substr($foldedPath, 0, $end); - if (!isset($seen[$foldedPrefix][$name])) { - $seen[$foldedPrefix][$name] = substr($path, 0, $offset).$name; - } - if (!$filtered) { - $relevant[$foldedPrefix] = true; + if (!isset($first[$foldedPrefix])) { + $first[$foldedPrefix] = substr($path, 0, $offset).$name; + } elseif (0 !== substr_compare($first[$foldedPrefix], $name, $offset) && !isset($variants[$foldedPrefix][$name])) { + // a name written the same way as the first one only differs by its + // parents, which get reported at their own level + $variants[$foldedPrefix][$name] = substr($path, 0, $offset).$name; } } $offset = $end + 1; } } + if (\count($variants) === 0) { + return []; + } + + // only look for paths which are not filtered out once something was found + /** @var array|null $relevant */ + $relevant = null; + if (false !== $duplicatesFilter) { + $relevant = []; + foreach ($paths as $path) { + if (Preg::isMatch($duplicatesFilter, $path)) { + continue; + } + $foldedPath = self::foldCase($path); + $offset = 0; + while (false !== ($separator = strpos($foldedPath, '/', $offset))) { + $relevant[substr($foldedPath, 0, $separator)] = true; + $offset = $separator + 1; + } + $relevant[$foldedPath] = true; + } + } + $ambiguousPaths = []; - foreach ($seen as $foldedPrefix => $variants) { - if (\count($variants) > 1 && isset($relevant[$foldedPrefix])) { + foreach ($variants as $foldedPrefix => $names) { + if (null === $relevant || isset($relevant[$foldedPrefix])) { // already sorted as the paths were walked in order - $ambiguousPaths[$foldedPrefix] = array_values($variants); + $ambiguousPaths[$foldedPrefix] = array_merge([$first[$foldedPrefix]], array_values($names)); } } - // sort the groups by folded path, as they got created whenever a second variant was seen ksort($ambiguousPaths, SORT_STRING); return array_values($ambiguousPaths); From cc6f8f3c016d18ef3d0f11131bb934fbfaccdfb7 Mon Sep 17 00:00:00 2001 From: Jordi Boggiano Date: Fri, 25 Sep 2026 16:55:34 +0200 Subject: [PATCH 14/14] Explain how the common prefix with the previous path is computed --- src/ClassMap.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ClassMap.php b/src/ClassMap.php index 09892f1..87ebf71 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -156,7 +156,9 @@ public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES $variants = []; $previous = ''; foreach ($paths as $path) { + // length of the common prefix with the previous path, as XOR turns equal bytes into \0 $common = strspn($path ^ $previous, "\0"); + // cut back to the last / within it, as only whole folders were registered already $skipUntil = $common > 0 ? (int) strrpos($path, '/', $common - \strlen($path) - 1) : 0; $previous = $path;