From 08c679b94490d243f3b7fe2d9f2562472a0abbaa Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 29 May 2026 08:03:26 +0700 Subject: [PATCH 1/4] Fix PSR-4 namespace resolution for overlapping prefix paths by selecting longest matching subdirectory --- src/Rule/Rules/Composer/Psr4NamespaceRule.php | 30 ++++++++++++------- tests/Rule/Composer/Psr4NamespaceRuleTest.php | 25 ++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/Rule/Rules/Composer/Psr4NamespaceRule.php b/src/Rule/Rules/Composer/Psr4NamespaceRule.php index 05ba023e..e159e479 100644 --- a/src/Rule/Rules/Composer/Psr4NamespaceRule.php +++ b/src/Rule/Rules/Composer/Psr4NamespaceRule.php @@ -69,6 +69,9 @@ private function expectedClassName(string $file): ?string $file = $this->normalisePath($file); + $bestPrefix = null; + $bestNamespace = null; + foreach ($this->mappingsFor($basePath) as $namespace => $paths) { foreach ($paths as $path) { $prefix = $this->normalisePath($basePath . '/' . $path); @@ -77,21 +80,28 @@ private function expectedClassName(string $file): ?string continue; } - $relativeClass = substr($file, strlen($prefix) + 1); - - if (! str_ends_with($relativeClass, '.php')) { - continue; + if ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix)) { + $bestPrefix = $prefix; + $bestNamespace = $namespace; } + } + } + + if ($bestPrefix === null || $bestNamespace === null) { + return null; + } - $relativeClass = substr($relativeClass, 0, -4); - $relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass); - $relativeClass = str_replace('/', '\\', $relativeClass); + $relativeClass = substr($file, strlen($bestPrefix) + 1); - return $namespace . ltrim($relativeClass, '\\'); - } + if (! str_ends_with($relativeClass, '.php')) { + return null; } - return null; + $relativeClass = substr($relativeClass, 0, -4); + $relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass); + $relativeClass = str_replace('/', '\\', $relativeClass); + + return $bestNamespace . ltrim($relativeClass, '\\'); } private function basePathFor(string $file): ?string diff --git a/tests/Rule/Composer/Psr4NamespaceRuleTest.php b/tests/Rule/Composer/Psr4NamespaceRuleTest.php index 205813d2..e77f73eb 100644 --- a/tests/Rule/Composer/Psr4NamespaceRuleTest.php +++ b/tests/Rule/Composer/Psr4NamespaceRuleTest.php @@ -179,6 +179,31 @@ public function testCachesMappingsPerBasePath(): void ); } + public function testSelectsLongestPrefixMatchNotFirstDeclared(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-psr4-longest-prefix'); + mkdir($basePath . '/src/legacy', 0777, true); + + file_put_contents($basePath . '/composer.json', json_encode([ + 'autoload' => [ + 'psr-4' => [ + 'App\\' => 'src/', + 'Legacy\\' => 'src/legacy/', + ], + ], + ])); + + $file = $basePath . '/src/legacy/Thing.php'; + file_put_contents($file, 'assertNotInstanceOf( + RuleViolation::class, + $psr4NamespaceRule->evaluate($this->makeNode('Legacy\\Thing', $file)) + ); + } + private function makeNode( string $className, string $file, From 47ce389acb75c2c1ca61c8a466764d1931b404ed Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 29 May 2026 08:06:29 +0700 Subject: [PATCH 2/4] add more test --- tests/Rule/Composer/Psr4NamespaceRuleTest.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/Rule/Composer/Psr4NamespaceRuleTest.php b/tests/Rule/Composer/Psr4NamespaceRuleTest.php index e77f73eb..5dccfd80 100644 --- a/tests/Rule/Composer/Psr4NamespaceRuleTest.php +++ b/tests/Rule/Composer/Psr4NamespaceRuleTest.php @@ -204,6 +204,31 @@ public function testSelectsLongestPrefixMatchNotFirstDeclared(): void ); } + public function testSelectsLongestPrefixMatchNotLastDeclared(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-psr4-longest-prefix-flipped'); + mkdir($basePath . '/src/legacy', 0777, true); + + file_put_contents($basePath . '/composer.json', json_encode([ + 'autoload' => [ + 'psr-4' => [ + 'Legacy\\' => 'src/legacy/', + 'App\\' => 'src/', + ], + ], + ])); + + $file = $basePath . '/src/legacy/Thing.php'; + file_put_contents($file, 'assertNotInstanceOf( + RuleViolation::class, + $psr4NamespaceRule->evaluate($this->makeNode('Legacy\\Thing', $file)) + ); + } + private function makeNode( string $className, string $file, From 85a07f2a6475b77efb90d811b7baa755b4c14fe8 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 29 May 2026 08:26:18 +0700 Subject: [PATCH 3/4] fix --- src/Rule/Rules/Composer/Psr4NamespaceRule.php | 46 +++++++++---------- tests/Rule/Composer/Psr4NamespaceRuleTest.php | 25 ++++++++++ 2 files changed, 48 insertions(+), 23 deletions(-) diff --git a/src/Rule/Rules/Composer/Psr4NamespaceRule.php b/src/Rule/Rules/Composer/Psr4NamespaceRule.php index e159e479..d049af77 100644 --- a/src/Rule/Rules/Composer/Psr4NamespaceRule.php +++ b/src/Rule/Rules/Composer/Psr4NamespaceRule.php @@ -9,8 +9,11 @@ use Boundwize\StructArmed\Rule\RuleInterface; use Boundwize\StructArmed\Rule\RuleViolation; +use function array_values; use function dirname; use function file_exists; +use function in_array; +use function krsort; use function ltrim; use function preg_replace; use function realpath; @@ -40,9 +43,9 @@ public function appliesTo(ClassNode $classNode): bool public function evaluate(ClassNode $classNode): ?RuleViolation { - $expectedClassName = $this->expectedClassName($classNode->file); + $expectedClassNames = $this->expectedClassNames($classNode->file); - if ($expectedClassName === null || $classNode->className === $expectedClassName) { + if ($expectedClassNames === [] || in_array($classNode->className, $expectedClassNames, true)) { return null; } @@ -50,7 +53,7 @@ public function evaluate(ClassNode $classNode): ?RuleViolation message: sprintf( 'Class [%s] must match PSR-4 class [%s]', $classNode->className, - $expectedClassName + $expectedClassNames[0] ), file: $classNode->file, line: $classNode->line, @@ -59,18 +62,20 @@ className: $classNode->className, ); } - private function expectedClassName(string $file): ?string + /** + * @return list + */ + private function expectedClassNames(string $file): array { $basePath = $this->basePathFor($file); if ($basePath === null) { - return null; + return []; } $file = $this->normalisePath($file); - $bestPrefix = null; - $bestNamespace = null; + $candidates = []; foreach ($this->mappingsFor($basePath) as $namespace => $paths) { foreach ($paths as $path) { @@ -80,28 +85,23 @@ private function expectedClassName(string $file): ?string continue; } - if ($bestPrefix === null || strlen($prefix) > strlen($bestPrefix)) { - $bestPrefix = $prefix; - $bestNamespace = $namespace; - } - } - } + $relativeClass = substr($file, strlen($prefix) + 1); - if ($bestPrefix === null || $bestNamespace === null) { - return null; - } + if (! str_ends_with($relativeClass, '.php')) { + continue; + } - $relativeClass = substr($file, strlen($bestPrefix) + 1); + $relativeClass = substr($relativeClass, 0, -4); + $relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass); + $relativeClass = str_replace('/', '\\', $relativeClass); - if (! str_ends_with($relativeClass, '.php')) { - return null; + $candidates[strlen($prefix)] = $namespace . ltrim($relativeClass, '\\'); + } } - $relativeClass = substr($relativeClass, 0, -4); - $relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass); - $relativeClass = str_replace('/', '\\', $relativeClass); + krsort($candidates); - return $bestNamespace . ltrim($relativeClass, '\\'); + return array_values($candidates); } private function basePathFor(string $file): ?string diff --git a/tests/Rule/Composer/Psr4NamespaceRuleTest.php b/tests/Rule/Composer/Psr4NamespaceRuleTest.php index 5dccfd80..acd3f910 100644 --- a/tests/Rule/Composer/Psr4NamespaceRuleTest.php +++ b/tests/Rule/Composer/Psr4NamespaceRuleTest.php @@ -229,6 +229,31 @@ public function testSelectsLongestPrefixMatchNotLastDeclared(): void ); } + public function testAllowsShortPrefixNamespaceWhenFileAlsoMatchesLongerPrefix(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-psr4-overlapping-prefix'); + mkdir($basePath . '/src/Legacy', 0777, true); + + file_put_contents($basePath . '/composer.json', json_encode([ + 'autoload' => [ + 'psr-4' => [ + 'App\\' => 'src/', + 'Legacy\\' => 'src/Legacy/', + ], + ], + ])); + + $file = $basePath . '/src/Legacy/Foo.php'; + file_put_contents($file, 'assertNotInstanceOf( + RuleViolation::class, + $psr4NamespaceRule->evaluate($this->makeNode('App\\Legacy\\Foo', $file)) + ); + } + private function makeNode( string $className, string $file, From 7aa5c69051baf46cf4f2aaf6d82a551b873e4a90 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 29 May 2026 08:32:45 +0700 Subject: [PATCH 4/4] more compare name --- src/Rule/Rules/Composer/Psr4NamespaceRule.php | 10 +++---- tests/Rule/Composer/Psr4NamespaceRuleTest.php | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/Rule/Rules/Composer/Psr4NamespaceRule.php b/src/Rule/Rules/Composer/Psr4NamespaceRule.php index d049af77..af39e52e 100644 --- a/src/Rule/Rules/Composer/Psr4NamespaceRule.php +++ b/src/Rule/Rules/Composer/Psr4NamespaceRule.php @@ -9,11 +9,10 @@ use Boundwize\StructArmed\Rule\RuleInterface; use Boundwize\StructArmed\Rule\RuleViolation; -use function array_values; +use function array_column; use function dirname; use function file_exists; use function in_array; -use function krsort; use function ltrim; use function preg_replace; use function realpath; @@ -24,6 +23,7 @@ use function str_starts_with; use function strlen; use function substr; +use function usort; final class Psr4NamespaceRule implements RuleInterface { @@ -95,13 +95,13 @@ private function expectedClassNames(string $file): array $relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass); $relativeClass = str_replace('/', '\\', $relativeClass); - $candidates[strlen($prefix)] = $namespace . ltrim($relativeClass, '\\'); + $candidates[] = ['length' => strlen($prefix), 'name' => $namespace . ltrim($relativeClass, '\\')]; } } - krsort($candidates); + usort($candidates, static fn(array $a, array $b): int => $b['length'] <=> $a['length']); - return array_values($candidates); + return array_column($candidates, 'name'); } private function basePathFor(string $file): ?string diff --git a/tests/Rule/Composer/Psr4NamespaceRuleTest.php b/tests/Rule/Composer/Psr4NamespaceRuleTest.php index acd3f910..d4dcef68 100644 --- a/tests/Rule/Composer/Psr4NamespaceRuleTest.php +++ b/tests/Rule/Composer/Psr4NamespaceRuleTest.php @@ -254,6 +254,36 @@ public function testAllowsShortPrefixNamespaceWhenFileAlsoMatchesLongerPrefix(): ); } + public function testAllowsEitherNamespaceWhenTwoNamespacesMapToSameDirectory(): void + { + $basePath = $this->makeTemporaryDirectory('structarmed-psr4-same-length-prefix'); + mkdir($basePath . '/src/Bar', 0777, true); + + file_put_contents($basePath . '/composer.json', json_encode([ + 'autoload' => [ + 'psr-4' => [ + 'App\\' => 'src/', + 'Foo\\' => 'src/', + ], + ], + ])); + + $file = $basePath . '/src/Bar/Baz.php'; + file_put_contents($file, 'assertNotInstanceOf( + RuleViolation::class, + $psr4NamespaceRule->evaluate($this->makeNode('App\\Bar\\Baz', $file)) + ); + + $this->assertNotInstanceOf( + RuleViolation::class, + $psr4NamespaceRule->evaluate($this->makeNode('Foo\\Bar\\Baz', $file)) + ); + } + private function makeNode( string $className, string $file,