Skip to content

Add ClassMap::getAmbiguousFolders to detect paths only differing in casing - #49

Merged
Seldaek merged 14 commits into
composer:mainfrom
Seldaek:ambiguous-folder-detection
Sep 25, 2026
Merged

Seldaek merged 14 commits into
composer:mainfrom
Seldaek:ambiguous-folder-detection

Conversation

@Seldaek

@Seldaek Seldaek commented Sep 25, 2026

Copy link
Copy Markdown
Member

Adds ClassMap::getAmbiguousFolders(), which returns sets of paths that only differ in casing and therefore merge into a single file or folder on case-insensitive filesystems (Windows, macOS). This supports composer/composer#13007.

This builds on @PrinsFrank's #44 (its two commits are included as-is) and supersedes it, with review fixes on top:

  • Results no longer depend on the order in which files were scanned.
  • Paths of ambiguous classes are included, which covers the typical case of a folder renamed to a different casing with stale copies left behind.
  • Every folder or file whose own name differs in casing is reported, not only the topmost one, since renaming the parent does not make the children match. Differences only inherited from a parent are not repeated.
  • The casing of drive letters and stream wrapper schemes is ignored.
  • The duplicates filter only hides a set when every path below it is filtered, so a test folder merging with a production folder is still reported.
  • The default duplicates filter now also matches relative paths, which applies to getAmbiguousClasses() too.
  • The common case of finding nothing is optimized: sorted paths skip the folders they share with the previous one, and the filter is only evaluated once something was found.

The first commit adds failing tests for all of the above, and each following commit fixes one of them.

PrinsFrank and others added 14 commits September 25, 2026 16:52
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MZmJZgxdTKD4zekKJDrmi9
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.
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.
ASCII folding preserves offsets, so the folded prefixes can be cut from
the folded path in lockstep with the original.
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.
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.
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.
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.
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.
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.
…thing

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants