From f1f8665981b0c0ee885f92a713929875dbcfa0bf Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 30 Jun 2026 21:48:31 +0700 Subject: [PATCH 01/13] Register composer.json to counted files on progressbar --- src/Analyser/Analyser.php | 16 ++- src/Cli/AnalyseCommand.php | 11 ++- tests/Cli/StructArmedApplicationTest.php | 120 +++++++++++++++++++++++ 3 files changed, 143 insertions(+), 4 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 97421e8..0731196 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -895,7 +895,7 @@ private function collectPhpFiles(array $layers, array $scanPaths, array $skipPat ); if (is_file($fullPath)) { - if (str_ends_with($fullPath, '.php') && ! $this->isSkipped($fullPath, $skipMatchers)) { + if ($this->isAnalysableFile($fullPath) && ! $this->isSkipped($fullPath, $skipMatchers)) { $files[] = $fullPath; } @@ -918,6 +918,11 @@ private function collectPhpFiles(array $layers, array $scanPaths, array $skipPat return array_values(array_unique($files)); } + private function isAnalysableFile(string $file): bool + { + return str_ends_with($file, '.php') || str_ends_with($file, '/composer.json'); + } + /** * @return array> */ @@ -927,7 +932,14 @@ private function resolveLayers(Architecture $architecture): array foreach ($layers as $layerName => $layerPaths) { if ($layerName === 'Source' && $layerPaths === []) { - $layers[$layerName] = (new Psr4PathResolver())->paths($this->basePath); + $paths = (new Psr4PathResolver())->paths($this->basePath); + $composerFile = Path::resolve('composer.json', $this->basePath); + + if (is_file($composerFile)) { + $paths[] = 'composer.json'; + } + + $layers[$layerName] = $paths; break; } } diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 4554c14..91150cf 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -122,13 +122,20 @@ public function run(array $arguments, string $basePath): int } foreach ($scanPaths as $scanPath) { - $fullScanPath = Path::resolve($scanPath, $basePath); + $fullScanPath = Path::resolve($scanPath, $basePath); + $normalisedFullScanPath = Path::normalise($fullScanPath); if (is_dir($fullScanPath)) { continue; } - if (is_file($fullScanPath) && str_ends_with($fullScanPath, '.php')) { + if ( + is_file($fullScanPath) + && ( + str_ends_with($normalisedFullScanPath, '.php') + || str_ends_with($normalisedFullScanPath, '/composer.json') + ) + ) { continue; } diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 51e5ddb..0bc5292 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -29,6 +29,7 @@ use function ob_start; use function preg_replace; use function random_bytes; +use function realpath; use function rmdir; use function serialize; use function str_replace; @@ -485,6 +486,94 @@ public function testAnalyseCommandFixesFixableViolations(): void } } + public function testAnalyseCommandTracksComposerJsonProgressAsSingleFile(): void + { + $basePath = $this->createProjectDirectoryWithMissingComposerPsr4Path(); + $progress = new class implements ProgressHandlerInterface { + /** @var list */ + public array $totals = []; + + /** @var list */ + public array $files = []; + + public function start(int $total): void + { + $this->totals[] = $total; + } + + public function advance(string $file): void + { + $this->files[] = $file; + } + + public function finish(): void + { + } + }; + + try { + $composerFile = $this->normalisePath((string) realpath($basePath . '/composer.json')); + + [$firstExitCode, $firstOutput] = $this->runAnalyseCommand( + ['--config=' . $basePath . '/structarmed.php'], + $basePath, + $progress + ); + + $this->assertSame(1, $firstExitCode, $firstOutput); + $this->assertStringContainsString('declared in composer.json do not exist on disk', $firstOutput); + $this->assertSame([1], $progress->totals); + $this->assertCount(1, $progress->files); + $this->assertSame( + $composerFile, + $this->normalisePath($progress->files[0]) + ); + + $fixProgress = new class implements ProgressHandlerInterface { + /** @var list */ + public array $totals = []; + + /** @var list */ + public array $files = []; + + public function start(int $total): void + { + $this->totals[] = $total; + } + + public function advance(string $file): void + { + $this->files[] = $file; + } + + public function finish(): void + { + } + }; + + [$exitCode, $output] = $this->runAnalyseCommand( + [ + '--config=' . $basePath . '/structarmed.php', + '--clear-cache', + '--fix', + ], + $basePath, + $fixProgress + ); + + $this->assertSame(0, $exitCode, $output); + $this->assertStringContainsString('1 violation has been fixed.', $this->withoutAnsi($output)); + $this->assertSame([1], $fixProgress->totals); + $this->assertCount(1, $fixProgress->files); + $this->assertSame( + $composerFile, + $this->normalisePath($fixProgress->files[0]) + ); + } finally { + $this->removeTempDirectory($basePath); + } + } + public function testAnalyseCommandReportsPluralFixedViolations(): void { $basePath = $this->createProjectDirectoryWithImplicitMethodVisibilityViolation(<<<'PHP' @@ -1217,6 +1306,33 @@ private function createProjectDirectory(): string return $basePath; } + private function createProjectDirectoryWithMissingComposerPsr4Path(): string + { + $basePath = $this->createTempDirectory(); + file_put_contents($basePath . '/composer.json', <<<'JSON' +{ + "autoload": { + "psr-4": { + "Missing\\": "missing/" + } + } +} +JSON); + file_put_contents($basePath . '/structarmed.php', <<<'PHP' +withPreset(Preset::PSR4()); +PHP); + + return $basePath; + } + private function createProjectDirectoryWithViolation(): string { $basePath = $this->createProjectDirectory(); @@ -1355,6 +1471,10 @@ private function removeTempDirectory(string $basePath): void unlink($basePath . '/structarmed-baseline.php'); } + if (file_exists($basePath . '/composer.json')) { + unlink($basePath . '/composer.json'); + } + foreach (glob($basePath . '/var/cache/structarmed/*.json') ?: [] as $cacheFile) { unlink($cacheFile); } From 40625da4671e7ce3cc55f3020e0f68776c547a26 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 30 Jun 2026 21:54:35 +0700 Subject: [PATCH 02/13] check root composer.json --- src/Analyser/Analyser.php | 10 +++++++++- src/Cli/AnalyseCommand.php | 6 +++++- tests/Cli/StructArmedApplicationTest.php | 22 ++++++++++++++++++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 0731196..55b877c 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -920,7 +920,15 @@ private function collectPhpFiles(array $layers, array $scanPaths, array $skipPat private function isAnalysableFile(string $file): bool { - return str_ends_with($file, '.php') || str_ends_with($file, '/composer.json'); + return str_ends_with($file, '.php') || $this->isRootComposerFile($file); + } + + private function isRootComposerFile(string $file): bool + { + return Path::normalise($file, canonicalise: true) === Path::normalise( + Path::resolve('composer.json', $this->basePath), + canonicalise: true, + ); } /** diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 91150cf..1347e65 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -124,6 +124,10 @@ public function run(array $arguments, string $basePath): int foreach ($scanPaths as $scanPath) { $fullScanPath = Path::resolve($scanPath, $basePath); $normalisedFullScanPath = Path::normalise($fullScanPath); + $rootComposerFile = Path::normalise( + Path::resolve('composer.json', $basePath), + canonicalise: true, + ); if (is_dir($fullScanPath)) { continue; @@ -133,7 +137,7 @@ public function run(array $arguments, string $basePath): int is_file($fullScanPath) && ( str_ends_with($normalisedFullScanPath, '.php') - || str_ends_with($normalisedFullScanPath, '/composer.json') + || Path::normalise($fullScanPath, canonicalise: true) === $rootComposerFile ) ) { continue; diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 0bc5292..2870cf3 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -569,6 +569,18 @@ public function finish(): void $composerFile, $this->normalisePath($fixProgress->files[0]) ); + + [$nestedExitCode, $nestedOutput] = $this->runAnalyseCommand( + [ + 'nested/composer.json', + '--config=' . $basePath . '/structarmed.php', + ], + $basePath, + $fixProgress + ); + + $this->assertSame(1, $nestedExitCode, $nestedOutput); + $this->assertStringContainsString('Error: path [nested/composer.json] not found.', $nestedOutput); } finally { $this->removeTempDirectory($basePath); } @@ -1318,6 +1330,8 @@ private function createProjectDirectoryWithMissingComposerPsr4Path(): string } } JSON); + mkdir($basePath . '/nested'); + file_put_contents($basePath . '/nested/composer.json', '{}'); file_put_contents($basePath . '/structarmed.php', <<<'PHP' Date: Tue, 30 Jun 2026 22:00:42 +0700 Subject: [PATCH 03/13] check root composer.json --- src/Analyser/Analyser.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 55b877c..76bb359 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -925,6 +925,10 @@ private function isAnalysableFile(string $file): bool private function isRootComposerFile(string $file): bool { + if (! str_ends_with($file, '/composer.json')) { + return false; + } + return Path::normalise($file, canonicalise: true) === Path::normalise( Path::resolve('composer.json', $this->basePath), canonicalise: true, From a12f955f4ee01e7914fd2f89ad5fcea3f8883068 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 30 Jun 2026 22:04:20 +0700 Subject: [PATCH 04/13] update screenshot --- docs/assets/no-violation.svg | 2 +- docs/assets/structarmed-showoff.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/assets/no-violation.svg b/docs/assets/no-violation.svg index f5b65d6..18dc36a 100644 --- a/docs/assets/no-violation.svg +++ b/docs/assets/no-violation.svg @@ -14,7 +14,7 @@ prj-ddd vendor/bin/structarmed analyze - StructArmed 0.14.6 — Architecture Enforcement + StructArmed 0.14.7 — Architecture Enforcement =============================================== diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg index 0bf7332..fc08b66 100644 --- a/docs/assets/structarmed-showoff.svg +++ b/docs/assets/structarmed-showoff.svg @@ -15,7 +15,7 @@ prj-ddd vendor/bin/structarmed analyze - StructArmed 0.14.6 — Architecture Enforcement + StructArmed 0.14.7 — Architecture Enforcement =============================================== From c281ae031db94d690c1b63c1af799f725d697447 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 30 Jun 2026 22:13:50 +0700 Subject: [PATCH 05/13] more tests --- tests/Analyser/AnalyserTest.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index a1d9883..a9b7586 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -667,18 +667,29 @@ public function testFilesForAnalysisWithAbsoluteScanPath(): void public function testFilesForAnalysisWithRootRelativeScanPath(): void { - $basePath = $this->makeTempProject([ - 'index.php' => ' 'makeTempProject([ + 'composer.json' => '{}', + 'index.php' => ' '{}', + 'src/Foo.php' => 'assertIsString($rootComposerFile); $architecture = Architecture::define() ->layer('Source', 'src/'); - $files = (new Analyser($basePath))->filesForAnalysis($architecture, ['index.php']); + $analyser = new Analyser($basePath); + $files = $analyser->filesForAnalysis($architecture, ['index.php']); $this->assertCount(1, $files); $this->assertStringEndsWith('/index.php', $this->normalisePath($files[0])); + $this->assertSame( + [$this->normalisePath($rootComposerFile)], + array_map($this->normalisePath(...), $analyser->filesForAnalysis($architecture, ['composer.json'])) + ); + $this->assertSame([], $analyser->filesForAnalysis($architecture, ['nested/composer.json'])); } public function testFilesForAnalysisUsesPreResolvedLayersWhenProvided(): void From 3c5f0f0dd82544b363c3c698effc398dc6f91836 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 30 Jun 2026 22:26:13 +0700 Subject: [PATCH 06/13] reuse analyzable file check --- src/Analyser/Analyser.php | 23 ++++------------------- src/Cli/AnalyseCommand.php | 16 ++-------------- src/Util/Path.php | 19 +++++++++++++++++++ tests/Util/PathTest.php | 21 +++++++++++++++++++++ 4 files changed, 46 insertions(+), 33 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 76bb359..a03386f 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -42,7 +42,6 @@ use function is_dir; use function is_file; use function sprintf; -use function str_ends_with; use function str_starts_with; use function strpbrk; use function substr; @@ -895,7 +894,10 @@ private function collectPhpFiles(array $layers, array $scanPaths, array $skipPat ); if (is_file($fullPath)) { - if ($this->isAnalysableFile($fullPath) && ! $this->isSkipped($fullPath, $skipMatchers)) { + if ( + Path::isAnalysableFile($fullPath, $this->basePath) + && ! $this->isSkipped($fullPath, $skipMatchers) + ) { $files[] = $fullPath; } @@ -918,23 +920,6 @@ private function collectPhpFiles(array $layers, array $scanPaths, array $skipPat return array_values(array_unique($files)); } - private function isAnalysableFile(string $file): bool - { - return str_ends_with($file, '.php') || $this->isRootComposerFile($file); - } - - private function isRootComposerFile(string $file): bool - { - if (! str_ends_with($file, '/composer.json')) { - return false; - } - - return Path::normalise($file, canonicalise: true) === Path::normalise( - Path::resolve('composer.json', $this->basePath), - canonicalise: true, - ); - } - /** * @return array> */ diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 1347e65..3861a97 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -27,7 +27,6 @@ use function is_file; use function microtime; use function sprintf; -use function str_ends_with; use function str_starts_with; use function strlen; use function substr; @@ -122,24 +121,13 @@ public function run(array $arguments, string $basePath): int } foreach ($scanPaths as $scanPath) { - $fullScanPath = Path::resolve($scanPath, $basePath); - $normalisedFullScanPath = Path::normalise($fullScanPath); - $rootComposerFile = Path::normalise( - Path::resolve('composer.json', $basePath), - canonicalise: true, - ); + $fullScanPath = Path::resolve($scanPath, $basePath); if (is_dir($fullScanPath)) { continue; } - if ( - is_file($fullScanPath) - && ( - str_ends_with($normalisedFullScanPath, '.php') - || Path::normalise($fullScanPath, canonicalise: true) === $rootComposerFile - ) - ) { + if (is_file($fullScanPath) && Path::isAnalysableFile($fullScanPath, $basePath)) { continue; } diff --git a/src/Util/Path.php b/src/Util/Path.php index b1e2316..50556dd 100644 --- a/src/Util/Path.php +++ b/src/Util/Path.php @@ -8,6 +8,7 @@ use function preg_replace; use function realpath; use function rtrim; +use function str_ends_with; use function str_replace; use function str_starts_with; use function strlen; @@ -64,6 +65,24 @@ public static function resolve(string $path, string $basePath): string : rtrim($basePath, '/\\') . '/' . $path; } + public static function isAnalysableFile(string $path, string $basePath): bool + { + if (str_ends_with($path, '.php')) { + return true; + } + + if (! str_ends_with($path, 'composer.json')) { + return false; + } + + $resolvedPath = self::resolve($path, $basePath); + + return self::normalise($resolvedPath, canonicalise: true) === self::normalise( + self::resolve('composer.json', $basePath), + canonicalise: true, + ); + } + private static function isAbsolute(string $path): bool { if (str_starts_with($path, '/') || str_starts_with($path, '\\\\')) { diff --git a/tests/Util/PathTest.php b/tests/Util/PathTest.php index 91eba3d..88a8052 100644 --- a/tests/Util/PathTest.php +++ b/tests/Util/PathTest.php @@ -48,12 +48,33 @@ public static function provideResolve(): Iterator yield 'windows UNC absolute' => ['\\\\server\\share\\src', '/project', '\\\\server\\share\\src']; } + /** + * @return Iterator + */ + public static function provideIsAnalysableFile(): Iterator + { + yield 'relative php' => ['src/Foo.php', '/project', true]; + yield 'absolute php' => ['/project/src/Foo.php', '/project', true]; + yield 'base-relative root composer' => ['composer.json', '/project', true]; + yield 'absolute root composer' => ['/project/composer.json', '/project', true]; + yield 'nested composer' => ['/project/nested/composer.json', '/project', false]; + yield 'other root composer' => ['/other/composer.json', '/project', false]; + yield 'composer lock' => ['/project/composer.lock', '/project', false]; + yield 'readme' => ['/project/README.md', '/project', false]; + } + #[DataProvider('provideResolve')] public function testResolve(string $path, string $basePath, string $expected): void { $this->assertSame($expected, Path::resolve($path, $basePath)); } + #[DataProvider('provideIsAnalysableFile')] + public function testIsAnalysableFile(string $path, string $basePath, bool $expected): void + { + $this->assertSame($expected, Path::isAnalysableFile($path, $basePath)); + } + public function testMemoisesNormalisedAndResolvedPaths(): void { $path = __DIR__ . '/../Util/PathTest.php'; From a3acba9db311e1349cad74dfaebf758d45e49d1e Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 30 Jun 2026 22:39:28 +0700 Subject: [PATCH 07/13] consistent with Source paths --- src/Analyser/Analyser.php | 26 ++++++++++++++++-------- tests/Analyser/AnalyserTest.php | 26 +++++++++++++++++++++++- tests/Cli/StructArmedApplicationTest.php | 4 +++- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index a03386f..1d690a1 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -870,12 +870,27 @@ private function collectClassNodes( public function filesForAnalysis(Architecture $architecture, array $scanPaths = [], ?array $layers = null): array { return $this->collectPhpFiles( - $layers ?? $this->resolveLayers($architecture), + $this->withRootComposerFileInSourceLayer($layers ?? $this->resolveLayers($architecture)), $scanPaths, $architecture->getSkipPaths() ); } + /** + * @param array> $layers + * @return array> + */ + private function withRootComposerFileInSourceLayer(array $layers): array + { + if (! array_key_exists('Source', $layers)) { + return $layers; + } + + $layers['Source'] = [...(array) $layers['Source'], 'composer.json']; + + return $layers; + } + /** * @param array> $layers * @param list $scanPaths @@ -929,14 +944,7 @@ private function resolveLayers(Architecture $architecture): array foreach ($layers as $layerName => $layerPaths) { if ($layerName === 'Source' && $layerPaths === []) { - $paths = (new Psr4PathResolver())->paths($this->basePath); - $composerFile = Path::resolve('composer.json', $this->basePath); - - if (is_file($composerFile)) { - $paths[] = 'composer.json'; - } - - $layers[$layerName] = $paths; + $layers[$layerName] = (new Psr4PathResolver())->paths($this->basePath); break; } } diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index a9b7586..d7a4987 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -303,11 +303,35 @@ public function testAnalyserContinuesWhenProjectRulePasses(): void $architecture = Architecture::define() ->withPreset(Preset::PSR4(sourcePaths: ['src/'])); - $ruleViolationCollection = (new Analyser($basePath))->analyse($architecture); + $analyser = new Analyser($basePath); + $files = array_map($this->normalisePath(...), $analyser->filesForAnalysis($architecture)); + + sort($files); + + $this->assertCount(2, $files); + $this->assertStringEndsWith('/composer.json', $files[0]); + $this->assertStringEndsWith('/src/Foo.php', $files[1]); + + $ruleViolationCollection = $analyser->analyse($architecture); $this->assertFalse($ruleViolationCollection->hasViolations()); } + public function testFilesForAnalysisIgnoresMissingRootComposerJsonCandidate(): void + { + $basePath = $this->makeTempProject([ + 'src/Foo.php' => 'withPreset(Preset::PSR4(sourcePaths: ['src/'])); + + $files = array_map($this->normalisePath(...), (new Analyser($basePath))->filesForAnalysis($architecture)); + + $this->assertCount(1, $files); + $this->assertStringEndsWith('/src/Foo.php', $files[0]); + } + public function testAnalyserUsesComposerPsr4PathsForDefaultPsr4Preset(): void { $basePath = $this->makeTempProject([ diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 2870cf3..83ef921 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -1325,11 +1325,13 @@ private function createProjectDirectoryWithMissingComposerPsr4Path(): string { "autoload": { "psr-4": { + "App\\": "src/", "Missing\\": "missing/" } } } JSON); + mkdir($basePath . '/src'); mkdir($basePath . '/nested'); file_put_contents($basePath . '/nested/composer.json', '{}'); file_put_contents($basePath . '/structarmed.php', <<<'PHP' @@ -1341,7 +1343,7 @@ private function createProjectDirectoryWithMissingComposerPsr4Path(): string use Boundwize\StructArmed\Preset\Preset; return Architecture::define() - ->withPreset(Preset::PSR4()); + ->withPreset(Preset::PSR4(sourcePaths: ['src/'])); PHP); return $basePath; From cbb320b4f627511cabf6dc736dc2a2e35a19afc6 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 30 Jun 2026 23:00:42 +0700 Subject: [PATCH 08/13] fix multiple add composer.json --- src/Analyser/Analyser.php | 20 +++++- .../Composer/Psr4DirectoryExistsRule.php | 6 ++ .../Composer/Psr4EmptyNamespacePrefixRule.php | 8 ++- src/Rule/Rules/Composer/Psr4RootPathRule.php | 8 ++- .../Rules/Composer/Psr4SourcePathsRule.php | 6 ++ .../Rules/Composer/SkipsComposerFileTrait.php | 50 ++++++++++++++ tests/Analyser/AnalyserTest.php | 65 +++++++++++++++++++ 7 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 src/Rule/Rules/Composer/SkipsComposerFileTrait.php diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 1d690a1..31e3b6e 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -882,15 +882,29 @@ public function filesForAnalysis(Architecture $architecture, array $scanPaths = */ private function withRootComposerFileInSourceLayer(array $layers): array { - if (! array_key_exists('Source', $layers)) { - return $layers; + foreach ($layers as $layerPaths) { + if (in_array('composer.json', (array) $layerPaths, true)) { + return $layers; + } } - $layers['Source'] = [...(array) $layers['Source'], 'composer.json']; + foreach ($layers as $layerName => $layerPaths) { + if (! $this->isSourceLayerName($layerName)) { + continue; + } + + $layers[$layerName] = [...(array) $layerPaths, 'composer.json']; + break; + } return $layers; } + private function isSourceLayerName(string $layerName): bool + { + return $layerName === 'Source' || str_starts_with($layerName, 'Source['); + } + /** * @param array> $layers * @param list $scanPaths diff --git a/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php b/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php index b342ba6..0298780 100644 --- a/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php +++ b/src/Rule/Rules/Composer/Psr4DirectoryExistsRule.php @@ -20,6 +20,8 @@ final readonly class Psr4DirectoryExistsRule extends AbstractJsonRecastFixableRule implements ProjectRuleInterface { + use SkipsComposerFileTrait; + public function __construct( private Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(), ) { @@ -29,6 +31,10 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar { $composerFile = rtrim($basePath, '/') . '/composer.json'; + if ($this->isComposerFileSkipped($basePath, $composerFile, $skipPaths)) { + return null; + } + if (! file_exists($composerFile)) { return $this->violation( 'composer.json was not found', diff --git a/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php b/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php index 551a642..98ff32e 100644 --- a/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php +++ b/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php @@ -19,6 +19,8 @@ final readonly class Psr4EmptyNamespacePrefixRule implements MultipleProjectRuleViolationInterface { + use SkipsComposerFileTrait; + public function __construct( private Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(), ) { @@ -31,12 +33,16 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar /** * @return list - * @param string[] $skipPaths + * @param list $skipPaths */ public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array { $composerFile = rtrim($basePath, '/') . '/composer.json'; + if ($this->isComposerFileSkipped($basePath, $composerFile, $skipPaths)) { + return []; + } + if (! file_exists($composerFile)) { return []; } diff --git a/src/Rule/Rules/Composer/Psr4RootPathRule.php b/src/Rule/Rules/Composer/Psr4RootPathRule.php index 6b5b607..272b41b 100644 --- a/src/Rule/Rules/Composer/Psr4RootPathRule.php +++ b/src/Rule/Rules/Composer/Psr4RootPathRule.php @@ -18,6 +18,8 @@ final readonly class Psr4RootPathRule implements MultipleProjectRuleViolationInterface { + use SkipsComposerFileTrait; + public function __construct( private Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(), ) { @@ -30,12 +32,16 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar /** * @return list - * @param string[] $skipPaths + * @param list $skipPaths */ public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array { $composerFile = rtrim($basePath, '/') . '/composer.json'; + if ($this->isComposerFileSkipped($basePath, $composerFile, $skipPaths)) { + return []; + } + if (! file_exists($composerFile)) { return []; } diff --git a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php index f099361..fddd7d0 100644 --- a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php +++ b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php @@ -23,6 +23,8 @@ final readonly class Psr4SourcePathsRule implements ProjectRuleInterface { + use SkipsComposerFileTrait; + /** * @param list $sourcePaths */ @@ -48,6 +50,10 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar { $composerFile = rtrim($basePath, '/') . '/composer.json'; + if ($this->isComposerFileSkipped($basePath, $composerFile, $skipPaths)) { + return null; + } + if (! file_exists($composerFile)) { return $this->violation( 'composer.json was not found', diff --git a/src/Rule/Rules/Composer/SkipsComposerFileTrait.php b/src/Rule/Rules/Composer/SkipsComposerFileTrait.php new file mode 100644 index 0000000..dcd7024 --- /dev/null +++ b/src/Rule/Rules/Composer/SkipsComposerFileTrait.php @@ -0,0 +1,50 @@ + $skipPaths + */ + private function isComposerFileSkipped(string $basePath, string $composerFile, array $skipPaths): bool + { + if ($skipPaths === []) { + return false; + } + + $normalisedBasePath = Path::normalise($basePath, canonicalise: true); + $normalisedComposerFile = Path::normalise($composerFile, canonicalise: true); + + foreach ($skipPaths as $skipPath) { + $absoluteSkipPath = Path::resolve(Path::normalise($skipPath), $normalisedBasePath); + + if (strpbrk($absoluteSkipPath, '*?[') !== false) { + if (fnmatch($absoluteSkipPath, $normalisedComposerFile)) { + return true; + } + + continue; + } + + $normalisedSkipPath = Path::normalise($absoluteSkipPath, canonicalise: true); + + if ( + $normalisedComposerFile === $normalisedSkipPath + || str_starts_with($normalisedComposerFile, $normalisedSkipPath . '/') + ) { + return true; + } + } + + return false; + } +} diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index d7a4987..a947501 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -13,6 +13,7 @@ use Boundwize\StructArmed\Preset\Preset; use Boundwize\StructArmed\Preset\Presets\Psr15Preset; use Boundwize\StructArmed\Preset\Presets\Psr1Preset; +use Boundwize\StructArmed\Preset\Presets\Psr4Preset; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; @@ -25,6 +26,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionMethod; use function array_map; use function count; @@ -254,6 +256,24 @@ public function testAnalyserEvaluatesProjectRules(): void $this->assertCount(1, $ruleViolationCollection->forRule('psr4.source_paths.must_be_in_composer')); } + public function testAnalyserSkipsComposerProjectRuleWithExplicitComposerJsonRuleSkip(): void + { + $basePath = $this->makeTempProject([ + 'composer.json' => '{"autoload":{"psr-4":{"App\\\\":"src/"}}}', + 'src/Foo.php' => 'withPreset(Preset::PSR4(sourcePaths: ['src/', 'tests/'])) + ->skip([ + Psr4Preset::SOURCE_PATHS_MUST_BE_IN_COMPOSER => ['composer.json'], + ]); + + $ruleViolationCollection = (new Analyser($basePath))->analyse($architecture); + + $this->assertCount(0, $ruleViolationCollection->forRule(Psr4Preset::SOURCE_PATHS_MUST_BE_IN_COMPOSER)); + } + public function testAnalyserSkipPathOnProjectRuleSuppressesViolations(): void { $basePath = $this->makeTempProject([ @@ -332,6 +352,51 @@ public function testFilesForAnalysisIgnoresMissingRootComposerJsonCandidate(): v $this->assertStringEndsWith('/src/Foo.php', $files[0]); } + public function testFilesForAnalysisIncludesRootComposerJsonForUniqueSourceLayerName(): void + { + $basePath = $this->makeTempProject([ + 'composer.json' => '{"autoload":{"psr-4":{"App\\\\":"src/"}}}', + 'src/Foo.php' => 'normalisePath(...), + $analyser->filesForAnalysis(Architecture::define(), [], ['Source[src/]' => ['src/']]) + ); + + sort($files); + + $this->assertCount(2, $files); + $this->assertStringEndsWith('/composer.json', $files[0]); + $this->assertStringEndsWith('/src/Foo.php', $files[1]); + } + + public function testRootComposerJsonCandidateIsAddedOnlyOnceAcrossSourceLayerNames(): void + { + $reflectionMethod = new ReflectionMethod(Analyser::class, 'withRootComposerFileInSourceLayer'); + + $layers = $reflectionMethod->invoke(new Analyser('/project'), [ + 'Source' => ['src/'], + 'Source[tests/]' => ['tests/'], + ]); + + $this->assertIsArray($layers); + + $composerJsonCount = 0; + foreach ($layers as $layerPaths) { + foreach ((array) $layerPaths as $layerPath) { + if ($layerPath === 'composer.json') { + ++$composerJsonCount; + } + } + } + + $this->assertSame(1, $composerJsonCount); + $this->assertSame(['src/', 'composer.json'], $layers['Source']); + $this->assertSame(['tests/'], $layers['Source[tests/]']); + } + public function testAnalyserUsesComposerPsr4PathsForDefaultPsr4Preset(): void { $basePath = $this->makeTempProject([ From 4ab9dc151e85b3b7fbe226fb4876b1b4d1aa7964 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 1 Jul 2026 09:58:46 +0700 Subject: [PATCH 09/13] make use of ComposerJsonRuleInterface --- src/Analyser/Analyser.php | 100 +++++++++++++----- src/Rule/ComposerJsonRuleInterface.php | 9 ++ .../Composer/Psr4DirectoryExistsRule.php | 10 +- .../Composer/Psr4EmptyNamespacePrefixRule.php | 11 +- src/Rule/Rules/Composer/Psr4RootPathRule.php | 9 +- .../Rules/Composer/Psr4SourcePathsRule.php | 10 +- .../Rules/Composer/SkipsComposerFileTrait.php | 50 --------- tests/Analyser/AnalyserTest.php | 71 +++++++++---- 8 files changed, 143 insertions(+), 127 deletions(-) create mode 100644 src/Rule/ComposerJsonRuleInterface.php delete mode 100644 src/Rule/Rules/Composer/SkipsComposerFileTrait.php diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 31e3b6e..d58ed78 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -11,6 +11,7 @@ use Boundwize\StructArmed\Composer\Psr4PathResolver; use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FixableInterface; use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; @@ -105,16 +106,21 @@ public function analyse( continue; } + $projectRuleSkipPaths = $ruleSkipPaths[$key] ?? []; + if ($rule instanceof MultipleProjectRuleViolationInterface) { - $projectRuleViolations[$key] = $rule->evaluateProjectAll( - $this->basePath, - $architecture, - $ruleSkipPaths[$key] ?? [] + $projectRuleViolations[$key] = $this->withoutSkippedProjectViolations( + $rule->evaluateProjectAll($this->basePath, $architecture, $projectRuleSkipPaths), + $projectRuleSkipPaths, ); } else { - $single = $rule->evaluateProject($this->basePath, $architecture, $ruleSkipPaths[$key] ?? []); + $single = $rule->evaluateProject($this->basePath, $architecture, $projectRuleSkipPaths); + $violations = $single instanceof RuleViolation ? [$single] : []; - $projectRuleViolations[$key] = $single instanceof RuleViolation ? [$single] : []; + $projectRuleViolations[$key] = $this->withoutSkippedProjectViolations( + $violations, + $projectRuleSkipPaths, + ); } } @@ -862,6 +868,26 @@ private function collectClassNodes( return new ExtractionResult($classNodes, $fileAnalyses); } + /** + * @param list $violations + * @param list $skipPaths + * @return list + */ + private function withoutSkippedProjectViolations(array $violations, array $skipPaths): array + { + if ($violations === [] || $skipPaths === []) { + return $violations; + } + + $skipMatchers = $this->compileSkipMatchers($skipPaths); + + return array_values(array_filter( + $violations, + fn(RuleViolation $ruleViolation): bool => $ruleViolation->file === '' + || ! $this->isSkipped($ruleViolation->file, $skipMatchers), + )); + } + /** * @param list $scanPaths * @param array>|null $layers @@ -870,39 +896,50 @@ private function collectClassNodes( public function filesForAnalysis(Architecture $architecture, array $scanPaths = [], ?array $layers = null): array { return $this->collectPhpFiles( - $this->withRootComposerFileInSourceLayer($layers ?? $this->resolveLayers($architecture)), + $layers ?? $this->resolveLayers($architecture), $scanPaths, - $architecture->getSkipPaths() + $architecture->getSkipPaths(), + $this->shouldAnalyseComposerJson($architecture), ); } - /** - * @param array> $layers - * @return array> - */ - private function withRootComposerFileInSourceLayer(array $layers): array + private function shouldAnalyseComposerJson(Architecture $architecture): bool { - foreach ($layers as $layerPaths) { - if (in_array('composer.json', (array) $layerPaths, true)) { - return $layers; + $skippedRuleKeys = $this->skippedRuleKeyMap($architecture->getSkippedRuleKeys()); + $ruleSkipPaths = $architecture->getRuleSkipPaths(); + + foreach ($architecture->getRules() as $key => $rule) { + if (array_key_exists($key, $skippedRuleKeys)) { + continue; } - } - foreach ($layers as $layerName => $layerPaths) { - if (! $this->isSourceLayerName($layerName)) { + if (! $rule instanceof ComposerJsonRuleInterface) { + continue; + } + + if ($this->isComposerJsonSkippedByRule($ruleSkipPaths[$key] ?? [])) { continue; } - $layers[$layerName] = [...(array) $layerPaths, 'composer.json']; - break; + return true; } - return $layers; + return false; } - private function isSourceLayerName(string $layerName): bool + /** + * @param list $skipPaths + */ + private function isComposerJsonSkippedByRule(array $skipPaths): bool { - return $layerName === 'Source' || str_starts_with($layerName, 'Source['); + if ($skipPaths === []) { + return false; + } + + return $this->isSkipped( + Path::resolve('composer.json', $this->normalisedBasePath), + $this->compileSkipMatchers($skipPaths) + ); } /** @@ -911,12 +948,21 @@ private function isSourceLayerName(string $layerName): bool * @param list $skipPaths * @return list */ - private function collectPhpFiles(array $layers, array $scanPaths, array $skipPaths): array - { + private function collectPhpFiles( + array $layers, + array $scanPaths, + array $skipPaths, + bool $includeComposerJson, + ): array { $files = []; $skipMatchers = $this->compileSkipMatchers($skipPaths); + $scanPaths = $this->scanPaths($layers, $scanPaths); + + if ($includeComposerJson) { + $scanPaths[] = 'composer.json'; + } - foreach ($this->scanPaths($layers, $scanPaths) as $layerPath) { + foreach (array_values(array_unique($scanPaths)) as $layerPath) { $fullPath = Path::normalise( Path::resolve($layerPath, $this->basePath), canonicalise: true diff --git a/src/Rule/ComposerJsonRuleInterface.php b/src/Rule/ComposerJsonRuleInterface.php new file mode 100644 index 0000000..9258e47 --- /dev/null +++ b/src/Rule/ComposerJsonRuleInterface.php @@ -0,0 +1,9 @@ +isComposerFileSkipped($basePath, $composerFile, $skipPaths)) { - return null; - } - if (! file_exists($composerFile)) { return $this->violation( 'composer.json was not found', diff --git a/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php b/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php index 98ff32e..5842400 100644 --- a/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php +++ b/src/Rule/Rules/Composer/Psr4EmptyNamespacePrefixRule.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Composer\Psr4PathResolver; +use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface; use Boundwize\StructArmed\Rule\MultipleProjectRuleViolationInterface; use Boundwize\StructArmed\Rule\RuleViolation; @@ -17,10 +18,10 @@ use function sprintf; use function trim; -final readonly class Psr4EmptyNamespacePrefixRule implements MultipleProjectRuleViolationInterface +final readonly class Psr4EmptyNamespacePrefixRule implements + MultipleProjectRuleViolationInterface, + ComposerJsonRuleInterface { - use SkipsComposerFileTrait; - public function __construct( private Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(), ) { @@ -39,10 +40,6 @@ public function evaluateProjectAll(string $basePath, Architecture $architecture, { $composerFile = rtrim($basePath, '/') . '/composer.json'; - if ($this->isComposerFileSkipped($basePath, $composerFile, $skipPaths)) { - return []; - } - if (! file_exists($composerFile)) { return []; } diff --git a/src/Rule/Rules/Composer/Psr4RootPathRule.php b/src/Rule/Rules/Composer/Psr4RootPathRule.php index 272b41b..0907660 100644 --- a/src/Rule/Rules/Composer/Psr4RootPathRule.php +++ b/src/Rule/Rules/Composer/Psr4RootPathRule.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Composer\Psr4PathResolver; +use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface; use Boundwize\StructArmed\Rule\MultipleProjectRuleViolationInterface; use Boundwize\StructArmed\Rule\RuleViolation; @@ -16,10 +17,8 @@ use function sprintf; use function trim; -final readonly class Psr4RootPathRule implements MultipleProjectRuleViolationInterface +final readonly class Psr4RootPathRule implements MultipleProjectRuleViolationInterface, ComposerJsonRuleInterface { - use SkipsComposerFileTrait; - public function __construct( private Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(), ) { @@ -38,10 +37,6 @@ public function evaluateProjectAll(string $basePath, Architecture $architecture, { $composerFile = rtrim($basePath, '/') . '/composer.json'; - if ($this->isComposerFileSkipped($basePath, $composerFile, $skipPaths)) { - return []; - } - if (! file_exists($composerFile)) { return []; } diff --git a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php index fddd7d0..dc92246 100644 --- a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php +++ b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php @@ -6,7 +6,7 @@ use Boundwize\StructArmed\Architecture; use Boundwize\StructArmed\Composer\Psr4PathResolver; -use Boundwize\StructArmed\Rule\ProjectRuleInterface; +use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Util\Path; @@ -21,10 +21,8 @@ use function substr; use function trim; -final readonly class Psr4SourcePathsRule implements ProjectRuleInterface +final readonly class Psr4SourcePathsRule implements ComposerJsonRuleInterface { - use SkipsComposerFileTrait; - /** * @param list $sourcePaths */ @@ -50,10 +48,6 @@ public function evaluateProject(string $basePath, Architecture $architecture, ar { $composerFile = rtrim($basePath, '/') . '/composer.json'; - if ($this->isComposerFileSkipped($basePath, $composerFile, $skipPaths)) { - return null; - } - if (! file_exists($composerFile)) { return $this->violation( 'composer.json was not found', diff --git a/src/Rule/Rules/Composer/SkipsComposerFileTrait.php b/src/Rule/Rules/Composer/SkipsComposerFileTrait.php deleted file mode 100644 index dcd7024..0000000 --- a/src/Rule/Rules/Composer/SkipsComposerFileTrait.php +++ /dev/null @@ -1,50 +0,0 @@ - $skipPaths - */ - private function isComposerFileSkipped(string $basePath, string $composerFile, array $skipPaths): bool - { - if ($skipPaths === []) { - return false; - } - - $normalisedBasePath = Path::normalise($basePath, canonicalise: true); - $normalisedComposerFile = Path::normalise($composerFile, canonicalise: true); - - foreach ($skipPaths as $skipPath) { - $absoluteSkipPath = Path::resolve(Path::normalise($skipPath), $normalisedBasePath); - - if (strpbrk($absoluteSkipPath, '*?[') !== false) { - if (fnmatch($absoluteSkipPath, $normalisedComposerFile)) { - return true; - } - - continue; - } - - $normalisedSkipPath = Path::normalise($absoluteSkipPath, canonicalise: true); - - if ( - $normalisedComposerFile === $normalisedSkipPath - || str_starts_with($normalisedComposerFile, $normalisedSkipPath . '/') - ) { - return true; - } - } - - return false; - } -} diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index a947501..8f367fb 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -17,6 +17,7 @@ use Boundwize\StructArmed\Progress\ProgressHandlerInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; +use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; use Boundwize\StructArmed\Rule\Rules\File\Psr1PhpTagsRule; use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule; use Boundwize\StructArmed\Rule\Rules\Method\MaxMethodLengthRule; @@ -26,7 +27,6 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -use ReflectionMethod; use function array_map; use function count; @@ -352,17 +352,21 @@ public function testFilesForAnalysisIgnoresMissingRootComposerJsonCandidate(): v $this->assertStringEndsWith('/src/Foo.php', $files[0]); } - public function testFilesForAnalysisIncludesRootComposerJsonForUniqueSourceLayerName(): void + public function testFilesForAnalysisIncludesRootComposerJsonForComposerJsonRule(): void { $basePath = $this->makeTempProject([ 'composer.json' => '{"autoload":{"psr-4":{"App\\\\":"src/"}}}', 'src/Foo.php' => 'layer('Domain', 'src/') + ->rule('composer.source_paths', new Psr4SourcePathsRule(['src/'])); + $analyser = new Analyser($basePath); $files = array_map( $this->normalisePath(...), - $analyser->filesForAnalysis(Architecture::define(), [], ['Source[src/]' => ['src/']]) + $analyser->filesForAnalysis($architecture) ); sort($files); @@ -372,29 +376,56 @@ public function testFilesForAnalysisIncludesRootComposerJsonForUniqueSourceLayer $this->assertStringEndsWith('/src/Foo.php', $files[1]); } - public function testRootComposerJsonCandidateIsAddedOnlyOnceAcrossSourceLayerNames(): void + public function testFilesForAnalysisDoesNotIncludeRootComposerJsonWithoutComposerJsonRule(): void { - $reflectionMethod = new ReflectionMethod(Analyser::class, 'withRootComposerFileInSourceLayer'); + $basePath = $this->makeTempProject([ + 'composer.json' => '{"autoload":{"psr-4":{"App\\\\":"src/"}}}', + 'src/Foo.php' => 'layer('Domain', 'src/'); + + $files = array_map($this->normalisePath(...), (new Analyser($basePath))->filesForAnalysis($architecture)); - $layers = $reflectionMethod->invoke(new Analyser('/project'), [ - 'Source' => ['src/'], - 'Source[tests/]' => ['tests/'], + $this->assertCount(1, $files); + $this->assertStringEndsWith('/src/Foo.php', $files[0]); + } + + public function testFilesForAnalysisDoesNotIncludeRootComposerJsonWhenComposerJsonRuleSkipsIt(): void + { + $basePath = $this->makeTempProject([ + 'composer.json' => '{"autoload":{"psr-4":{"App\\\\":"src/"}}}', + 'src/Foo.php' => 'assertIsArray($layers); + $architecture = Architecture::define() + ->layer('Domain', 'src/') + ->rule('composer.source_paths', new Psr4SourcePathsRule(['src/'])) + ->skip(['composer.source_paths' => ['composer.json']]); - $composerJsonCount = 0; - foreach ($layers as $layerPaths) { - foreach ((array) $layerPaths as $layerPath) { - if ($layerPath === 'composer.json') { - ++$composerJsonCount; - } - } - } + $files = array_map($this->normalisePath(...), (new Analyser($basePath))->filesForAnalysis($architecture)); + + $this->assertCount(1, $files); + $this->assertStringEndsWith('/src/Foo.php', $files[0]); + } - $this->assertSame(1, $composerJsonCount); - $this->assertSame(['src/', 'composer.json'], $layers['Source']); - $this->assertSame(['tests/'], $layers['Source[tests/]']); + public function testFilesForAnalysisDoesNotIncludeRootComposerJsonWhenComposerJsonRuleIsSkipped(): void + { + $basePath = $this->makeTempProject([ + 'composer.json' => '{"autoload":{"psr-4":{"App\\\\":"src/"}}}', + 'src/Foo.php' => 'layer('Domain', 'src/') + ->rule('composer.source_paths', new Psr4SourcePathsRule(['src/'])) + ->skipRule('composer.source_paths'); + + $files = array_map($this->normalisePath(...), (new Analyser($basePath))->filesForAnalysis($architecture)); + + $this->assertCount(1, $files); + $this->assertStringEndsWith('/src/Foo.php', $files[0]); } public function testAnalyserUsesComposerPsr4PathsForDefaultPsr4Preset(): void From 0a85472eac3b52e74526620ffab7060e17830fca Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 1 Jul 2026 10:07:58 +0700 Subject: [PATCH 10/13] clean up --- src/Analyser/Analyser.php | 100 ++++++++++++++++---------------------- 1 file changed, 41 insertions(+), 59 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index d58ed78..08bc230 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -895,12 +895,47 @@ private function withoutSkippedProjectViolations(array $violations, array $skipP */ public function filesForAnalysis(Architecture $architecture, array $scanPaths = [], ?array $layers = null): array { - return $this->collectPhpFiles( - $layers ?? $this->resolveLayers($architecture), - $scanPaths, - $architecture->getSkipPaths(), - $this->shouldAnalyseComposerJson($architecture), - ); + $layers = $layers ?? $this->resolveLayers($architecture); + $files = []; + $skipPaths = $architecture->getSkipPaths(); + $skipMatchers = $this->compileSkipMatchers($skipPaths); + $scanPaths = $this->scanPaths($layers, $scanPaths); + + if ($this->shouldAnalyseComposerJson($architecture)) { + $scanPaths[] = 'composer.json'; + } + + foreach (array_values(array_unique($scanPaths)) as $layerPath) { + $fullPath = Path::normalise( + Path::resolve($layerPath, $this->basePath), + canonicalise: true + ); + + if (is_file($fullPath)) { + if ( + Path::isAnalysableFile($fullPath, $this->basePath) + && ! $this->isSkipped($fullPath, $skipMatchers) + ) { + $files[] = $fullPath; + } + + continue; + } + + if (! is_dir($fullPath)) { + continue; + } + + if ($this->isSkipped($fullPath, $skipMatchers)) { + continue; + } + + foreach ($this->phpFiles($fullPath, $skipMatchers) as $file) { + $files[] = $file; + } + } + + return array_values(array_unique($files)); } private function shouldAnalyseComposerJson(Architecture $architecture): bool @@ -942,59 +977,6 @@ private function isComposerJsonSkippedByRule(array $skipPaths): bool ); } - /** - * @param array> $layers - * @param list $scanPaths - * @param list $skipPaths - * @return list - */ - private function collectPhpFiles( - array $layers, - array $scanPaths, - array $skipPaths, - bool $includeComposerJson, - ): array { - $files = []; - $skipMatchers = $this->compileSkipMatchers($skipPaths); - $scanPaths = $this->scanPaths($layers, $scanPaths); - - if ($includeComposerJson) { - $scanPaths[] = 'composer.json'; - } - - foreach (array_values(array_unique($scanPaths)) as $layerPath) { - $fullPath = Path::normalise( - Path::resolve($layerPath, $this->basePath), - canonicalise: true - ); - - if (is_file($fullPath)) { - if ( - Path::isAnalysableFile($fullPath, $this->basePath) - && ! $this->isSkipped($fullPath, $skipMatchers) - ) { - $files[] = $fullPath; - } - - continue; - } - - if (! is_dir($fullPath)) { - continue; - } - - if ($this->isSkipped($fullPath, $skipMatchers)) { - continue; - } - - foreach ($this->phpFiles($fullPath, $skipMatchers) as $file) { - $files[] = $file; - } - } - - return array_values(array_unique($files)); - } - /** * @return array> */ From 9411de213dd789c5c726603c0c63c5306bfe8c3f Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 1 Jul 2026 10:12:37 +0700 Subject: [PATCH 11/13] fix phpstan --- tests/Cli/StructArmedApplicationCommandRoutingTest.php | 3 +-- tests/Cli/StructArmedApplicationTest.php | 6 ++---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index 67916fd..5d6589b 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -148,8 +148,7 @@ private function runApplication(array $argv, ?string $basePath = null): array ob_start(); $exitCode = (new StructArmedApplication())->run($argv, $basePath); $output = ob_get_clean(); - $this->assertIsString($output); - return [$exitCode, $output]; + return [$exitCode, $output === false ? '' : $output]; } } diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 83ef921..ecb9769 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -1279,9 +1279,8 @@ private function runApplication(array $argv, ?string $basePath = null): array ob_start(); $exitCode = (new StructArmedApplication())->run($argv, $basePath); $output = ob_get_clean(); - $this->assertIsString($output); - return [$exitCode, $output]; + return [$exitCode, $output === false ? '' : $output]; } /** @@ -1296,9 +1295,8 @@ private function runAnalyseCommand( ob_start(); $exitCode = (new AnalyseCommand($progressHandler))->run($arguments, $basePath); $output = ob_get_clean(); - $this->assertIsString($output); - return [$exitCode, $output]; + return [$exitCode, $output === false ? '' : $output]; } private function createProjectDirectory(): string From a6d0e34313d6ae6651e885f94805bcd6c909756d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 1 Jul 2026 10:17:26 +0700 Subject: [PATCH 12/13] fix phpstan --- tests/Cli/StructArmedApplicationCommandRoutingTest.php | 5 +++-- tests/Cli/StructArmedApplicationTest.php | 10 ++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index 5d6589b..947114b 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -147,8 +147,9 @@ private function runApplication(array $argv, ?string $basePath = null): array { ob_start(); $exitCode = (new StructArmedApplication())->run($argv, $basePath); - $output = ob_get_clean(); + $output = ob_get_contents(); + ob_end_clean(); - return [$exitCode, $output === false ? '' : $output]; + return [$exitCode, (string) $output]; } } diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index ecb9769..1f16c22 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -1278,9 +1278,10 @@ private function runApplication(array $argv, ?string $basePath = null): array { ob_start(); $exitCode = (new StructArmedApplication())->run($argv, $basePath); - $output = ob_get_clean(); + $output = ob_get_contents(); + ob_end_clean(); - return [$exitCode, $output === false ? '' : $output]; + return [$exitCode, (string) $output]; } /** @@ -1294,9 +1295,10 @@ private function runAnalyseCommand( ): array { ob_start(); $exitCode = (new AnalyseCommand($progressHandler))->run($arguments, $basePath); - $output = ob_get_clean(); + $output = ob_get_contents(); + ob_end_clean(); - return [$exitCode, $output === false ? '' : $output]; + return [$exitCode, (string) $output]; } private function createProjectDirectory(): string From 01e7b6300172fe59424991c71c012ad2c16cbf7a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Wed, 1 Jul 2026 10:18:04 +0700 Subject: [PATCH 13/13] phsptan --- src/Analyser/Analyser.php | 2 +- tests/Cli/StructArmedApplicationCommandRoutingTest.php | 3 ++- tests/Cli/StructArmedApplicationTest.php | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 08bc230..0e19337 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -895,7 +895,7 @@ private function withoutSkippedProjectViolations(array $violations, array $skipP */ public function filesForAnalysis(Architecture $architecture, array $scanPaths = [], ?array $layers = null): array { - $layers = $layers ?? $this->resolveLayers($architecture); + $layers ??= $this->resolveLayers($architecture); $files = []; $skipPaths = $architecture->getSkipPaths(); $skipMatchers = $this->compileSkipMatchers($skipPaths); diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index 947114b..1e54ce1 100644 --- a/tests/Cli/StructArmedApplicationCommandRoutingTest.php +++ b/tests/Cli/StructArmedApplicationCommandRoutingTest.php @@ -13,7 +13,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; -use function ob_get_clean; +use function ob_end_clean; +use function ob_get_contents; use function ob_start; use function sprintf; diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 1f16c22..e1a689d 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -25,7 +25,8 @@ use function is_dir; use function json_decode; use function mkdir; -use function ob_get_clean; +use function ob_end_clean; +use function ob_get_contents; use function ob_start; use function preg_replace; use function random_bytes;