From 97608f811a8562bc0de2e9d108ca062430d1dd1e Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 5 Sep 2026 00:17:34 +0700 Subject: [PATCH 1/2] Scope composer.json rules to the analysed file list on explicit path arguments --- src/Analyser/Analyser.php | 21 +++++++++++++++-- tests/Analyser/AnalyserTest.php | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index d5e73326..78b57dcc 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -80,6 +80,16 @@ public function analyse( $globalSkipPaths = $architecture->getSkipPaths(); $ruleSkipPaths = $architecture->getRuleSkipPaths(); $skippedRuleKeys = $this->skippedRuleKeyMap($architecture->getSkippedRuleKeys()); + $files ??= $this->filesForAnalysis($architecture, $scanPaths, $layers); + + // Composer rules follow the same scope as every other rule kind: they + // only run when the root composer.json is part of the file list, which + // a layer-wide scan includes and an explicit path argument may not. + $analysesComposerJson = in_array( + Path::normalise(Path::resolve('composer.json', $this->basePath), canonicalise: true), + $files, + true + ); $projectRuleViolations = []; $fileAnalysisRules = []; @@ -140,6 +150,10 @@ public function analyse( continue; } + if ($rule instanceof ComposerJsonRuleInterface && ! $analysesComposerJson) { + continue; + } + $projectRuleViolations[$key] = []; if ($rule instanceof FileAnalysisRuleInterface) { @@ -168,7 +182,6 @@ public function analyse( $layerPatterns = $architecture->getLayerPatterns(); $chainLayerResolver = ChainLayerResolver::fromLayerConfig($layers, $this->basePath, $layerPatterns); - $files ??= $this->filesForAnalysis($architecture, $scanPaths, $layers); $withFileAnalysis = $fileAnalysisRules !== []; $extractionResult = $this->collectAnalysisNodes( $files, @@ -1407,9 +1420,13 @@ public function filesForAnalysis(Architecture $architecture, array $scanPaths = $layers ??= $this->resolveLayers($architecture); $files = []; $skipPathMatcher = SkipPathMatcher::compile($this->basePath, $architecture->getSkipPaths()); + $isExplicitScan = $scanPaths !== []; $scanPaths = $this->scanPaths($layers, $scanPaths); - if ($this->shouldAnalyseComposerJson($architecture)) { + // The root composer.json joins a layer-wide scan automatically; an + // explicit path argument keeps the file list to what was named, so + // composer rules only run there when composer.json itself is named. + if (! $isExplicitScan && $this->shouldAnalyseComposerJson($architecture)) { $scanPaths[] = 'composer.json'; } diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 1c7f6d89..185d2b71 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -31,6 +31,7 @@ use Boundwize\StructArmed\Rule\FunctionRuleInterface; use Boundwize\StructArmed\Rule\Rules\Class_\AnonymousClassMayNotHaveEmptyParenthesesRule; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; +use Boundwize\StructArmed\Rule\Rules\Composer\Psr4DirectoryExistsRule; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; use Boundwize\StructArmed\Rule\Rules\File\Psr1PhpTagsRule; use Boundwize\StructArmed\Rule\Rules\File\Psr1SymbolsOrSideEffectsRule; @@ -2686,6 +2687,45 @@ public function testFilesForAnalysisDoesNotIncludeRootComposerJsonWhenComposerJs $this->assertStringEndsWith('/src/Foo.php', $files[0]); } + public function testFilesForAnalysisDoesNotIncludeRootComposerJsonForExplicitScanPath(): 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, ['src/Foo.php'])); + + $this->assertCount(1, $files); + $this->assertStringEndsWith('/src/Foo.php', $files[0]); + + $this->assertCount(2, $analyser->filesForAnalysis($architecture)); + } + + public function testAnalyserScopesComposerRulesToExplicitScanPaths(): void + { + $basePath = $this->makeTempProject([ + 'composer.json' => '{"autoload":{"psr-4":{"App\\\\":"src/","Missing\\\\":"missing/"}}}', + 'src/Foo.php' => 'layer('Domain', 'src/') + ->rule('composer.directory_exists', new Psr4DirectoryExistsRule()); + + $analyser = new Analyser($basePath); + + $this->assertCount(0, $analyser->analyse($architecture, ['src/Foo.php'])->forRule('composer.directory_exists')); + $this->assertCount(1, $analyser->analyse($architecture, ['composer.json'])->forRule('composer.directory_exists')); + $this->assertCount(1, $analyser->analyse($architecture)->forRule('composer.directory_exists')); + } + public function testFilesForAnalysisDoesNotIncludeRootComposerJsonWhenComposerJsonRuleIsSkipped(): void { $basePath = $this->makeTempProject([ From 39208b7e3d29f2df7e4631c820b55f48028fc9e1 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sat, 5 Sep 2026 00:20:59 +0700 Subject: [PATCH 2/2] simplify --- src/Analyser/Analyser.php | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 78b57dcc..050474df 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -85,11 +85,8 @@ public function analyse( // Composer rules follow the same scope as every other rule kind: they // only run when the root composer.json is part of the file list, which // a layer-wide scan includes and an explicit path argument may not. - $analysesComposerJson = in_array( - Path::normalise(Path::resolve('composer.json', $this->basePath), canonicalise: true), - $files, - true - ); + $composerJsonFile = Path::normalise(Path::resolve('composer.json', $this->basePath), canonicalise: true); + $isAnalysesComposerJson = in_array($composerJsonFile, $files, true); $projectRuleViolations = []; $fileAnalysisRules = []; @@ -150,7 +147,7 @@ public function analyse( continue; } - if ($rule instanceof ComposerJsonRuleInterface && ! $analysesComposerJson) { + if ($rule instanceof ComposerJsonRuleInterface && ! $isAnalysesComposerJson) { continue; }