From e6ec8de3d1770d10152e89920e8e797d04b71440 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 8 Nov 2026 06:55:25 +0700 Subject: [PATCH 1/2] fix: Resolve baseline file paths against the base path, not the working directory --- src/Baseline/Baseline.php | 16 ++++- tests/Cli/StructArmedApplicationTest.php | 87 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/Baseline/Baseline.php b/src/Baseline/Baseline.php index eab1de59..63e7bf33 100644 --- a/src/Baseline/Baseline.php +++ b/src/Baseline/Baseline.php @@ -85,7 +85,7 @@ public function generate( $violations = []; foreach ($ruleViolationCollection as $violation) { - $relativeFile = Path::relativeTo($violation->file, $normalisedBasePath); + $relativeFile = $this->relativeFile($violation->file, $normalisedBasePath); $entry = [ 'rule' => $violation->ruleKey, 'message' => $this->relativeMessagePath( @@ -185,7 +185,7 @@ private function arraySignature( array $messagePathPrefixes, ): string { $file = $this->stringValue($violation['file'] ?? null); - $relativeFile = Path::relativeTo($file, $normalisedBasePath); + $relativeFile = $this->relativeFile($file, $normalisedBasePath); return (string) json_encode([ 'rule' => $this->stringValue($violation['rule'] ?? null), @@ -201,6 +201,16 @@ private function arraySignature( ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); } + /** + * Resolved against the base path first: a baseline entry is stored relative + * to it, and realpath() would resolve a relative path, or an empty one, + * against the working directory instead. + */ + private function relativeFile(string $file, string $normalisedBasePath): string + { + return Path::relativeTo(Path::resolve($file, $normalisedBasePath), $normalisedBasePath); + } + private function stringValue(mixed $value): string { if (! is_scalar($value)) { @@ -216,7 +226,7 @@ private function signature( string $normalisedBasePath, array $messagePathPrefixes, ): string { - $relativeFile = Path::relativeTo($ruleViolation->file, $normalisedBasePath); + $relativeFile = $this->relativeFile($ruleViolation->file, $normalisedBasePath); return (string) json_encode([ 'rule' => $ruleViolation->ruleKey, diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 96e900e4..0c39b0ee 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -19,9 +19,11 @@ use PHPUnit\Framework\TestCase; use function bin2hex; +use function chdir; use function file_exists; use function file_get_contents; use function file_put_contents; +use function getcwd; use function glob; use function is_dir; use function json_decode; @@ -1448,6 +1450,91 @@ public function testAnalyseCommandGeneratesBaseline(): void } } + /** + * A baseline entry stores its file relative to the base path. Running from + * a monorepo root that holds a file at the same relative path must not + * resolve the entry to that file instead. + */ + public function testAnalyseCommandAppliesBaselineFromOtherWorkingDirectoryWithBasePathOption(): void + { + $rootPath = (string) realpath($this->createTempDirectory()); + $packagePath = $rootPath . '/packages/app'; + + mkdir($rootPath . '/src'); + mkdir($packagePath . '/src', 0777, true); + + $source = <<<'PHP' +layer('Source', 'src/') + ->rule('source.must_be_final', new MustBeFinalRule('Source')) + ->baseline('structarmed-baseline.php'); +PHP); + + $originalWorkingDirectory = getcwd(); + $this->assertNotFalse($originalWorkingDirectory); + + try { + [$generateExitCode, $generateOutput] = $this->runApplication( + [ + 'structarmed', + 'analyse', + '--no-progress', + '--no-cache', + '--generate-baseline=structarmed-baseline.php', + ], + $packagePath + ); + + $this->assertSame(0, $generateExitCode, $generateOutput); + $this->assertStringContainsString( + "'file' => 'src/Foo.php'", + (string) file_get_contents($packagePath . '/structarmed-baseline.php') + ); + + chdir($rootPath); + + [$exitCode, $output] = $this->runApplication( + [ + 'structarmed', + 'analyse', + '--basepath=packages/app', + '--config=packages/app/structarmed.php', + '--no-progress', + '--no-cache', + ], + $rootPath + ); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('No violations found.', $output); + } finally { + chdir($originalWorkingDirectory); + $this->removeTempDirectory($packagePath); + unlink($rootPath . '/src/Foo.php'); + rmdir($rootPath . '/src'); + rmdir($rootPath . '/packages'); + rmdir($rootPath); + } + } + public function testAnalyseCommandGeneratesBaselineWithSeparateOptionValue(): void { $basePath = $this->createProjectDirectoryWithViolation(); From 0c0043eed2e1cebcde2339dd388e8fe5be77d74b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 8 Nov 2026 06:59:10 +0700 Subject: [PATCH 2/2] patch on Path::relativeTo() --- src/Baseline/Baseline.php | 16 +++------------- src/Util/Path.php | 2 +- tests/Util/PathTest.php | 3 +++ 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/src/Baseline/Baseline.php b/src/Baseline/Baseline.php index 63e7bf33..eab1de59 100644 --- a/src/Baseline/Baseline.php +++ b/src/Baseline/Baseline.php @@ -85,7 +85,7 @@ public function generate( $violations = []; foreach ($ruleViolationCollection as $violation) { - $relativeFile = $this->relativeFile($violation->file, $normalisedBasePath); + $relativeFile = Path::relativeTo($violation->file, $normalisedBasePath); $entry = [ 'rule' => $violation->ruleKey, 'message' => $this->relativeMessagePath( @@ -185,7 +185,7 @@ private function arraySignature( array $messagePathPrefixes, ): string { $file = $this->stringValue($violation['file'] ?? null); - $relativeFile = $this->relativeFile($file, $normalisedBasePath); + $relativeFile = Path::relativeTo($file, $normalisedBasePath); return (string) json_encode([ 'rule' => $this->stringValue($violation['rule'] ?? null), @@ -201,16 +201,6 @@ private function arraySignature( ], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE); } - /** - * Resolved against the base path first: a baseline entry is stored relative - * to it, and realpath() would resolve a relative path, or an empty one, - * against the working directory instead. - */ - private function relativeFile(string $file, string $normalisedBasePath): string - { - return Path::relativeTo(Path::resolve($file, $normalisedBasePath), $normalisedBasePath); - } - private function stringValue(mixed $value): string { if (! is_scalar($value)) { @@ -226,7 +216,7 @@ private function signature( string $normalisedBasePath, array $messagePathPrefixes, ): string { - $relativeFile = $this->relativeFile($ruleViolation->file, $normalisedBasePath); + $relativeFile = Path::relativeTo($ruleViolation->file, $normalisedBasePath); return (string) json_encode([ 'rule' => $ruleViolation->ruleKey, diff --git a/src/Util/Path.php b/src/Util/Path.php index 088d9121..c6508b4d 100644 --- a/src/Util/Path.php +++ b/src/Util/Path.php @@ -95,7 +95,7 @@ public static function isAnalysableFile(string $path, string $basePath): bool */ public static function relativeTo(string $path, string $normalisedBasePath): string { - $normalisedPath = self::normalise($path, canonicalise: true); + $normalisedPath = self::normalise(self::resolve($path, $normalisedBasePath), canonicalise: true); if ($normalisedPath === $normalisedBasePath) { return ''; diff --git a/tests/Util/PathTest.php b/tests/Util/PathTest.php index 21926b7c..8090832c 100644 --- a/tests/Util/PathTest.php +++ b/tests/Util/PathTest.php @@ -92,6 +92,9 @@ public static function provideRelativeTo(): Iterator yield 'base itself' => ['/project', '/project', '']; yield 'sibling of base' => ['/shared/src/Foo.php', '/project', '../shared/src/Foo.php']; yield 'no common ancestor' => ['C:/other/Foo.php', '/project', 'C:/other/Foo.php']; + // resolved against the base path, never the working directory + yield 'relative to base' => ['src/Foo.php', '/project', 'src/Foo.php']; + yield 'empty is the base' => ['', '/project', '']; } #[DataProvider('provideRelativeTo')]