From 3e1b2e08794e09124cafb9ff9c7f330d798fe99d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 13 Nov 2026 08:56:00 +0700 Subject: [PATCH 1/2] fix: Match absolute out-of-project source paths against relative PSR-4 mappings in Psr4SourcePathsRule --- .../Rules/Composer/Psr4SourcePathsRule.php | 7 ++- .../Rule/Composer/Psr4SourcePathsRuleTest.php | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php index 26f58873..17e58d01 100644 --- a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php +++ b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php @@ -76,16 +76,19 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar private function normalisePaths(array $paths, string $basePath): array { $normalisedBase = Path::normalise($basePath); + $canonicalBase = Path::normalise($basePath, canonicalise: true); return array_map( - static function (string $path) use ($normalisedBase): string { + static function (string $path) use ($normalisedBase, $canonicalBase): string { $path = Path::normalise(trim($path)); if ($normalisedBase !== '' && str_starts_with($path, $normalisedBase . '/')) { return substr($path, strlen($normalisedBase) + 1); } - return $path; + // Out-of-project path (e.g. "../shared/src" or its absolute form): express + // it relative to the base path so both spellings compare equal. + return Path::relativeTo($path, $canonicalBase) ?: $path; }, $paths ); diff --git a/tests/Rule/Composer/Psr4SourcePathsRuleTest.php b/tests/Rule/Composer/Psr4SourcePathsRuleTest.php index 3ae7840a..ae93ce21 100644 --- a/tests/Rule/Composer/Psr4SourcePathsRuleTest.php +++ b/tests/Rule/Composer/Psr4SourcePathsRuleTest.php @@ -13,6 +13,7 @@ use function file_put_contents; use function json_encode; +use function mkdir; use const JSON_THROW_ON_ERROR; @@ -231,6 +232,53 @@ public function testPassesWhenComposerUsesAbsoluteSourcePath(): void ); } + public function testPassesWhenAbsoluteSourcePathOutsideProjectMatchesRelativeComposerPsr4Path(): void + { + $workspace = $this->makeTempDir(); + mkdir($workspace . '/app'); + mkdir($workspace . '/shared/src', 0777, true); + file_put_contents($workspace . '/app/composer.json', <<<'JSON' +{ + "autoload": { + "psr-4": { + "Shared\\": "../shared/src/" + } + } +} +JSON); + + $psr4SourcePathsRule = new Psr4SourcePathsRule([$workspace . '/shared/src']); + + $this->assertNotInstanceOf( + RuleViolation::class, + $psr4SourcePathsRule->evaluateProject($workspace . '/app', Architecture::define()) + ); + } + + public function testFailsWhenSourcePathOutsideProjectIsMissingFromComposerPsr4Autoloads(): void + { + $workspace = $this->makeTempDir(); + mkdir($workspace . '/app'); + mkdir($workspace . '/shared/src', 0777, true); + mkdir($workspace . '/other/src', 0777, true); + file_put_contents($workspace . '/app/composer.json', <<<'JSON' +{ + "autoload": { + "psr-4": { + "Shared\\": "../shared/src/" + } + } +} +JSON); + + $psr4SourcePathsRule = new Psr4SourcePathsRule([$workspace . '/other/src', '.']); + + $violation = $psr4SourcePathsRule->evaluateProject($workspace . '/app', Architecture::define()); + + $this->assertInstanceOf(RuleViolation::class, $violation); + $this->assertStringContainsString('[../other/src, .]', $violation->message); + } + private function makeTempProject(string $composerJson): string { $basePath = $this->makeTempDir(); From e56ba9f63266329162950977d0a98946188dc398 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 13 Nov 2026 08:59:35 +0700 Subject: [PATCH 2/2] fix path --- .../Rules/Composer/Psr4SourcePathsRule.php | 6 ++--- .../Rule/Composer/Psr4SourcePathsRuleTest.php | 25 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php index 17e58d01..3032d139 100644 --- a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php +++ b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php @@ -83,11 +83,11 @@ static function (string $path) use ($normalisedBase, $canonicalBase): string { $path = Path::normalise(trim($path)); if ($normalisedBase !== '' && str_starts_with($path, $normalisedBase . '/')) { - return substr($path, strlen($normalisedBase) + 1); + $path = substr($path, strlen($normalisedBase) + 1); } - // Out-of-project path (e.g. "../shared/src" or its absolute form): express - // it relative to the base path so both spellings compare equal. + // Express every spelling relative to the canonical base path, so absolute, + // relative, out-of-project ("../shared/src") and symlinked forms compare equal. return Path::relativeTo($path, $canonicalBase) ?: $path; }, $paths diff --git a/tests/Rule/Composer/Psr4SourcePathsRuleTest.php b/tests/Rule/Composer/Psr4SourcePathsRuleTest.php index ae93ce21..64ff29cb 100644 --- a/tests/Rule/Composer/Psr4SourcePathsRuleTest.php +++ b/tests/Rule/Composer/Psr4SourcePathsRuleTest.php @@ -14,6 +14,7 @@ use function file_put_contents; use function json_encode; use function mkdir; +use function symlink; use const JSON_THROW_ON_ERROR; @@ -279,6 +280,30 @@ public function testFailsWhenSourcePathOutsideProjectIsMissingFromComposerPsr4Au $this->assertStringContainsString('[../other/src, .]', $violation->message); } + public function testPassesWhenAbsoluteSourcePathIsInProjectSymlinkToOutsideDirectory(): void + { + $workspace = $this->makeTempDir(); + mkdir($workspace . '/app'); + mkdir($workspace . '/shared/src', 0777, true); + symlink($workspace . '/shared/src', $workspace . '/app/src'); + file_put_contents($workspace . '/app/composer.json', <<<'JSON' +{ + "autoload": { + "psr-4": { + "App\\": "src/" + } + } +} +JSON); + + $psr4SourcePathsRule = new Psr4SourcePathsRule([$workspace . '/app/src', 'src']); + + $this->assertNotInstanceOf( + RuleViolation::class, + $psr4SourcePathsRule->evaluateProject($workspace . '/app', Architecture::define()) + ); + } + private function makeTempProject(string $composerJson): string { $basePath = $this->makeTempDir();