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 =============================================== diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 97421e8..0e19337 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; @@ -42,7 +43,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; @@ -106,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, + ); } } @@ -864,38 +869,53 @@ private function collectClassNodes( } /** - * @param list $scanPaths - * @param array>|null $layers - * @return list + * @param list $violations + * @param list $skipPaths + * @return list */ - public function filesForAnalysis(Architecture $architecture, array $scanPaths = [], ?array $layers = null): array + private function withoutSkippedProjectViolations(array $violations, array $skipPaths): array { - return $this->collectPhpFiles( - $layers ?? $this->resolveLayers($architecture), - $scanPaths, - $architecture->getSkipPaths() - ); + 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 array> $layers * @param list $scanPaths - * @param list $skipPaths + * @param array>|null $layers * @return list */ - private function collectPhpFiles(array $layers, array $scanPaths, array $skipPaths): array + public function filesForAnalysis(Architecture $architecture, array $scanPaths = [], ?array $layers = null): array { + $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 ($this->scanPaths($layers, $scanPaths) as $layerPath) { + foreach (array_values(array_unique($scanPaths)) as $layerPath) { $fullPath = Path::normalise( Path::resolve($layerPath, $this->basePath), canonicalise: true ); if (is_file($fullPath)) { - if (str_ends_with($fullPath, '.php') && ! $this->isSkipped($fullPath, $skipMatchers)) { + if ( + Path::isAnalysableFile($fullPath, $this->basePath) + && ! $this->isSkipped($fullPath, $skipMatchers) + ) { $files[] = $fullPath; } @@ -918,6 +938,45 @@ private function collectPhpFiles(array $layers, array $scanPaths, array $skipPat return array_values(array_unique($files)); } + private function shouldAnalyseComposerJson(Architecture $architecture): bool + { + $skippedRuleKeys = $this->skippedRuleKeyMap($architecture->getSkippedRuleKeys()); + $ruleSkipPaths = $architecture->getRuleSkipPaths(); + + foreach ($architecture->getRules() as $key => $rule) { + if (array_key_exists($key, $skippedRuleKeys)) { + continue; + } + + if (! $rule instanceof ComposerJsonRuleInterface) { + continue; + } + + if ($this->isComposerJsonSkippedByRule($ruleSkipPaths[$key] ?? [])) { + continue; + } + + return true; + } + + return false; + } + + /** + * @param list $skipPaths + */ + private function isComposerJsonSkippedByRule(array $skipPaths): bool + { + if ($skipPaths === []) { + return false; + } + + return $this->isSkipped( + Path::resolve('composer.json', $this->normalisedBasePath), + $this->compileSkipMatchers($skipPaths) + ); + } + /** * @return array> */ diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 4554c14..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; @@ -128,7 +127,7 @@ public function run(array $arguments, string $basePath): int continue; } - if (is_file($fullScanPath) && str_ends_with($fullScanPath, '.php')) { + if (is_file($fullScanPath) && Path::isAnalysableFile($fullScanPath, $basePath)) { continue; } 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 @@ + - * @param string[] $skipPaths + * @param list $skipPaths */ public function evaluateProjectAll(string $basePath, Architecture $architecture, array $skipPaths = []): array { diff --git a/src/Rule/Rules/Composer/Psr4RootPathRule.php b/src/Rule/Rules/Composer/Psr4RootPathRule.php index 6b5b607..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,7 +17,7 @@ use function sprintf; use function trim; -final readonly class Psr4RootPathRule implements MultipleProjectRuleViolationInterface +final readonly class Psr4RootPathRule implements MultipleProjectRuleViolationInterface, ComposerJsonRuleInterface { public function __construct( private Psr4PathResolver $psr4PathResolver = new Psr4PathResolver(), @@ -30,7 +31,7 @@ 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 { diff --git a/src/Rule/Rules/Composer/Psr4SourcePathsRule.php b/src/Rule/Rules/Composer/Psr4SourcePathsRule.php index f099361..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,7 +21,7 @@ use function substr; use function trim; -final readonly class Psr4SourcePathsRule implements ProjectRuleInterface +final readonly class Psr4SourcePathsRule implements ComposerJsonRuleInterface { /** * @param list $sourcePaths 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/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index a1d9883..8f367fb 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -13,9 +13,11 @@ 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; +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; @@ -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([ @@ -303,11 +323,111 @@ 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 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) + ); + + sort($files); + + $this->assertCount(2, $files); + $this->assertStringEndsWith('/composer.json', $files[0]); + $this->assertStringEndsWith('/src/Foo.php', $files[1]); + } + + public function testFilesForAnalysisDoesNotIncludeRootComposerJsonWithoutComposerJsonRule(): void + { + $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)); + + $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' => 'layer('Domain', 'src/') + ->rule('composer.source_paths', new Psr4SourcePathsRule(['src/'])) + ->skip(['composer.source_paths' => ['composer.json']]); + + $files = array_map($this->normalisePath(...), (new Analyser($basePath))->filesForAnalysis($architecture)); + + $this->assertCount(1, $files); + $this->assertStringEndsWith('/src/Foo.php', $files[0]); + } + + 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 { $basePath = $this->makeTempProject([ @@ -667,18 +787,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 diff --git a/tests/Cli/StructArmedApplicationCommandRoutingTest.php b/tests/Cli/StructArmedApplicationCommandRoutingTest.php index 67916fd..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; @@ -147,9 +148,9 @@ private function runApplication(array $argv, ?string $basePath = null): array { ob_start(); $exitCode = (new StructArmedApplication())->run($argv, $basePath); - $output = ob_get_clean(); - $this->assertIsString($output); + $output = ob_get_contents(); + ob_end_clean(); - return [$exitCode, $output]; + return [$exitCode, (string) $output]; } } diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 51e5ddb..e1a689d 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -25,10 +25,12 @@ 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; +use function realpath; use function rmdir; use function serialize; use function str_replace; @@ -485,6 +487,106 @@ 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]) + ); + + [$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); + } + } + public function testAnalyseCommandReportsPluralFixedViolations(): void { $basePath = $this->createProjectDirectoryWithImplicitMethodVisibilityViolation(<<<'PHP' @@ -1177,10 +1279,10 @@ private function runApplication(array $argv, ?string $basePath = null): array { ob_start(); $exitCode = (new StructArmedApplication())->run($argv, $basePath); - $output = ob_get_clean(); - $this->assertIsString($output); + $output = ob_get_contents(); + ob_end_clean(); - return [$exitCode, $output]; + return [$exitCode, (string) $output]; } /** @@ -1194,10 +1296,10 @@ private function runAnalyseCommand( ): array { ob_start(); $exitCode = (new AnalyseCommand($progressHandler))->run($arguments, $basePath); - $output = ob_get_clean(); - $this->assertIsString($output); + $output = ob_get_contents(); + ob_end_clean(); - return [$exitCode, $output]; + return [$exitCode, (string) $output]; } private function createProjectDirectory(): string @@ -1217,6 +1319,37 @@ private function createProjectDirectory(): string return $basePath; } + private function createProjectDirectoryWithMissingComposerPsr4Path(): string + { + $basePath = $this->createTempDirectory(); + file_put_contents($basePath . '/composer.json', <<<'JSON' +{ + "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' +withPreset(Preset::PSR4(sourcePaths: ['src/'])); +PHP); + + return $basePath; + } + private function createProjectDirectoryWithViolation(): string { $basePath = $this->createProjectDirectory(); @@ -1355,6 +1488,14 @@ private function removeTempDirectory(string $basePath): void unlink($basePath . '/structarmed-baseline.php'); } + if (file_exists($basePath . '/composer.json')) { + unlink($basePath . '/composer.json'); + } + + if (file_exists($basePath . '/nested/composer.json')) { + unlink($basePath . '/nested/composer.json'); + } + foreach (glob($basePath . '/var/cache/structarmed/*.json') ?: [] as $cacheFile) { unlink($cacheFile); } @@ -1383,6 +1524,10 @@ private function removeTempDirectory(string $basePath): void rmdir($basePath . '/var'); } + if (is_dir($basePath . '/nested')) { + rmdir($basePath . '/nested'); + } + if (is_dir($basePath)) { rmdir($basePath); } 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';