From edb789141c129931a752fa325097749657aa297b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 27 Aug 2026 08:29:49 +0700 Subject: [PATCH] fix: Psr4NamespaceRule misses PSR-4 mappings pointing outside the project directory --- src/Rule/Rules/Composer/Psr4NamespaceRule.php | 60 ++++++++++++------- tests/Rule/Composer/Psr4NamespaceRuleTest.php | 39 ++++++++++++ 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/src/Rule/Rules/Composer/Psr4NamespaceRule.php b/src/Rule/Rules/Composer/Psr4NamespaceRule.php index fce9caae..aa8fa274 100644 --- a/src/Rule/Rules/Composer/Psr4NamespaceRule.php +++ b/src/Rule/Rules/Composer/Psr4NamespaceRule.php @@ -5,13 +5,16 @@ namespace Boundwize\StructArmed\Rule\Rules\Composer; use Boundwize\StructArmed\Analyser\ClassNode; +use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Composer\Psr4PathResolver; +use Boundwize\StructArmed\Rule\ProjectRuleInterface; use Boundwize\StructArmed\Rule\RuleInterface; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Util\Path; use function array_key_exists; use function array_key_first; +use function array_unique; use function arsort; use function dirname; use function file_exists; @@ -25,8 +28,10 @@ use function strlen; use function substr; -final class Psr4NamespaceRule implements RuleInterface +final class Psr4NamespaceRule implements RuleInterface, ProjectRuleInterface { + private ?string $projectBasePath = null; + /** @var array>> */ private array $mappingsByBasePath = []; @@ -44,6 +49,17 @@ public function appliesTo(ClassNode $classNode): bool return $classNode->isInLayer($this->layer); } + /** + * Records the analysed project root so PSR-4 paths pointing outside it + * (e.g. "../shared/src/") are still checked; project rules run before class rules. + */ + public function evaluateProject(string $basePath, Architecture $architecture, array $skipPaths = []): ?RuleViolation + { + $this->projectBasePath = Path::normalise($basePath, canonicalise: true); + + return null; + } + public function evaluate(ClassNode $classNode): ?RuleViolation { $expectedClassNames = $this->expectedClassNames($classNode->file); @@ -71,37 +87,39 @@ className: $classNode->className, */ private function expectedClassNames(string $file): array { - $basePath = $this->basePathFor($file); - - if ($basePath === null) { - return []; - } + $basePaths = array_unique([$this->projectBasePath, $this->basePathFor($file)]); $file = Path::normalise($file, canonicalise: true); $candidates = []; - foreach ($this->mappingsFor($basePath) as $namespace => $paths) { - foreach ($paths as $path) { - $prefix = Path::normalise(Path::resolve($path, $basePath), canonicalise: true); + foreach ($basePaths as $basePath) { + if ($basePath === null) { + continue; + } - if (! str_starts_with($file, $prefix . '/')) { - continue; - } + foreach ($this->mappingsFor($basePath) as $namespace => $paths) { + foreach ($paths as $path) { + $prefix = Path::normalise(Path::resolve($path, $basePath), canonicalise: true); - $relativeClass = substr($file, strlen($prefix) + 1); + if (! str_starts_with($file, $prefix . '/')) { + continue; + } - if (! str_ends_with($relativeClass, '.php')) { - continue; - } + $relativeClass = substr($file, strlen($prefix) + 1); + + if (! str_ends_with($relativeClass, '.php')) { + continue; + } - $relativeClass = substr($relativeClass, 0, -4); - $relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass); - $relativeClass = str_replace('/', '\\', $relativeClass); + $relativeClass = substr($relativeClass, 0, -4); + $relativeClass = (string) preg_replace('/\.class$/i', '', $relativeClass); + $relativeClass = str_replace('/', '\\', $relativeClass); - $className = $namespace . ltrim($relativeClass, '\\'); + $className = $namespace . ltrim($relativeClass, '\\'); - $candidates[$className] = max($candidates[$className] ?? 0, strlen($prefix)); + $candidates[$className] = max($candidates[$className] ?? 0, strlen($prefix)); + } } } diff --git a/tests/Rule/Composer/Psr4NamespaceRuleTest.php b/tests/Rule/Composer/Psr4NamespaceRuleTest.php index e2e072d8..73893a52 100644 --- a/tests/Rule/Composer/Psr4NamespaceRuleTest.php +++ b/tests/Rule/Composer/Psr4NamespaceRuleTest.php @@ -5,6 +5,7 @@ namespace Boundwize\StructArmed\Tests\Rule\Composer; use Boundwize\StructArmed\Analyser\ClassNode; +use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4NamespaceRule; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; @@ -74,6 +75,44 @@ public function testFailsWhenClassDoesNotMatchAbsoluteComposerPsr4Path(): void $this->assertSame('Class [Wrong\\Foo] must match PSR-4 class [App\\Foo]', $violation->message); } + public function testFailsForPsr4PathOutsideProjectDirectory(): void + { + $rootPath = $this->makeTemporaryDirectory('structarmed-psr4-outside-project'); + mkdir($rootPath . '/project'); + mkdir($rootPath . '/shared/src', 0777, true); + + file_put_contents($rootPath . '/project/composer.json', json_encode([ + 'autoload' => [ + 'psr-4' => [ + 'App\\' => '../shared/src/', + ], + ], + ])); + + $file = $rootPath . '/shared/src/Foo.php'; + file_put_contents($file, 'assertNotInstanceOf( + RuleViolation::class, + $psr4NamespaceRule->evaluateProject($rootPath . '/project', Architecture::define()) + ); + + $violation = $psr4NamespaceRule->evaluate($this->makeNode('Wrong\\Foo', $file)); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertSame('Class [Wrong\\Foo] must match PSR-4 class [App\\Foo]', $violation->message); + + mkdir($rootPath . '/shared/src/Sub'); + file_put_contents($rootPath . '/shared/src/Sub/Bar.php', 'assertNotInstanceOf( + RuleViolation::class, + $psr4NamespaceRule->evaluate($this->makeNode('App\\Sub\\Bar', $rootPath . '/shared/src/Sub/Bar.php')) + ); + } + #[DataProvider('nonClassKindProvider')] public function testViolationMessageNamesTheClassLikeKind( string $expectedKind,