diff --git a/docs/configuration.md b/docs/configuration.md index 5d95f6c3..eaa3af50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -79,6 +79,35 @@ Use `replaceRule()` to swap a preset rule's configuration: `replaceRule()` throws `RuleNotFoundException` if the target key does not exist, so typos are caught immediately. +## Importing Config Files + +Use `import()` to split a large config into per-module files: + +```php +import(__DIR__ . '/module/Foo/structarmed.php') + ->import(__DIR__ . '/module/Bar/structarmed.php'); +``` + +Each imported file returns a callable that receives the root `Architecture`: + +```php +layer('Foo', 'module/Foo/src/'); +}; +``` + +Paths in imported files are still resolved from the project root. Imported files can import other files. Changing any imported file invalidates the analysis cache, just like changing the main config. + ## Custom Extensions Use [Custom Rules And Presets](../custom-rules-and-presets/) when you want to add project-specific rules or package reusable rule sets. diff --git a/src/Architecture.php b/src/Architecture.php index 14d3b1fd..267bec8b 100644 --- a/src/Architecture.php +++ b/src/Architecture.php @@ -19,7 +19,9 @@ use function array_merge; use function array_unique; use function array_values; +use function is_callable; use function is_int; +use function realpath; use function sprintf; /** @@ -112,6 +114,12 @@ final class Architecture private ?string $baseline = null; + /** @var list config files applied through import(), nested imports included */ + private array $importedFiles = []; + + /** @var array config files whose import() is still running */ + private array $importing = []; + private function __construct() { } @@ -121,6 +129,53 @@ public static function define(): self return new self(); } + /** + * Apply another config file, which returns a callable that receives this + * Architecture: + * + * return Architecture::define() + * ->import(__DIR__ . '/module/Foo/structarmed.php') + * ->import(__DIR__ . '/module/Bar/structarmed.php'); + * + * // module/Foo/structarmed.php + * return static function (Architecture $architecture): void { + * $architecture->layer('Foo', 'module/Foo/src/'); + * }; + * + * Imported files are part of the config cache key, so changing one + * invalidates the analysis cache. + */ + public function import(string $file): self + { + $importedFile = realpath($file) ?: $file; + + // only imports still in progress are tracked, so a file imported twice + // through separate branches is applied again rather than rejected + if (isset($this->importing[$importedFile])) { + throw new InvalidArgumentException(sprintf('Circular import of configuration [%s].', $file)); + } + + $configure = require $file; + + if (! is_callable($configure)) { + throw new InvalidArgumentException( + sprintf('Imported configuration [%s] must return a callable.', $file) + ); + } + + $this->importing[$importedFile] = true; + + try { + $configure($this); + } finally { + unset($this->importing[$importedFile]); + } + + $this->importedFiles[] = $importedFile; + + return $this; + } + // ------------------------------------------------------------------------- // Layer definition // ------------------------------------------------------------------------- @@ -492,6 +547,12 @@ public function getBaseline(): ?string return $this->baseline; } + /** @return list */ + public function getImportedFiles(): array + { + return $this->importedFiles; + } + private function registerPendingSkip(string $skip): void { if (isset($this->rules[$skip])) { diff --git a/src/Cache/AnalysisCacheMetadataFactory.php b/src/Cache/AnalysisCacheMetadataFactory.php index a76833c8..5f7a04a0 100644 --- a/src/Cache/AnalysisCacheMetadataFactory.php +++ b/src/Cache/AnalysisCacheMetadataFactory.php @@ -68,6 +68,17 @@ public function fileHash(string $path): string return $this->fileHashProvider->hash($path); } + /** + * Files merged through Architecture::import() shape the config as much as + * the config file itself, so a change to any of them must change the hash. + * + * @param list $importedFiles + */ + public function configHash(string $configPath, array $importedFiles): string + { + return $this->filesHash([$configPath, ...$importedFiles]); + } + /** * Cached analysis nodes store resolved layer assignments, which depend on the * composer.json PSR-4 mappings as well as the config, so both hashes must diff --git a/src/Cli/AnalyseCommand.php b/src/Cli/AnalyseCommand.php index 586ce8f5..c2290c00 100644 --- a/src/Cli/AnalyseCommand.php +++ b/src/Cli/AnalyseCommand.php @@ -160,7 +160,10 @@ public function run(array $arguments, string $basePath): int // Without the cache, no file is hashed and no cache entry is read or // written, which saves that work on a one-off run over a huge codebase. if (! isset($options['no-cache'])) { - $configHash = $analysisCacheMetadataFactory->fileHash($configFile); + $configHash = $analysisCacheMetadataFactory->configHash( + $configFile, + $architecture->getImportedFiles() + ); $analysisResultCache = new AnalysisResultCache( $basePath, $fileHashProvider, diff --git a/src/PHPUnit/StructArmedExtension.php b/src/PHPUnit/StructArmedExtension.php index 6c22c258..3f3f8316 100644 --- a/src/PHPUnit/StructArmedExtension.php +++ b/src/PHPUnit/StructArmedExtension.php @@ -50,7 +50,10 @@ public function bootstrap( $start = microtime(true); $fileHashProvider = new FileHashProvider(); $analysisCacheMetadataFactory = new AnalysisCacheMetadataFactory($fileHashProvider); - $configHash = $analysisCacheMetadataFactory->fileHash($configFile); + $configHash = $analysisCacheMetadataFactory->configHash( + $configFile, + $architecture->getImportedFiles() + ); $composerGeneratedVersionHash = $analysisCacheMetadataFactory->composerGeneratedVersionHash(); $analysisResultCache = new AnalysisResultCache( $basePath, diff --git a/tests/ArchitectureTest.php b/tests/ArchitectureTest.php index 2d930065..9147da8e 100644 --- a/tests/ArchitectureTest.php +++ b/tests/ArchitectureTest.php @@ -13,16 +13,21 @@ use Boundwize\StructArmed\Preset\Presets\Psr4Preset; use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule; use Boundwize\StructArmed\Rule\Rules\Composer\Psr4SourcePathsRule; +use Boundwize\StructArmed\Tests\Support\TemporaryDirectoryCleanupTrait; use InvalidArgumentException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use function file_put_contents; +use function realpath; use function sprintf; #[CoversClass(Architecture::class)] final class ArchitectureTest extends TestCase { + use TemporaryDirectoryCleanupTrait; + public function testDefineReturnsNewInstance(): void { $this->assertInstanceOf(Architecture::class, Architecture::define()); @@ -450,4 +455,87 @@ public function testSkipPathsForRulesetAcceptsStringAndArray(): void $this->assertSame(['tests/', 'fixtures/', 'stubs/'], $architecture->getRulesetSkipPaths()); } + + public function testImportAppliesReturnedCallableAndTracksNestedFiles(): void + { + $directory = $this->makeTemporaryDirectory('structarmed-import'); + file_put_contents($directory . '/bar.php', <<<'PHP' + layer('Bar', 'module/Bar/src/'); + }; + + PHP); + file_put_contents($directory . '/foo.php', <<<'PHP' + layer('Foo', 'module/Foo/src/') + ->import(__DIR__ . '/bar.php'); + }; + + PHP); + + // bar.php again after foo.php imported it: a repeated import is not a cycle + $architecture = Architecture::define() + ->layer('App', 'src/') + ->import($directory . '/foo.php') + ->import($directory . '/bar.php'); + + $this->assertSame( + ['App' => 'src/', 'Foo' => 'module/Foo/src/', 'Bar' => 'module/Bar/src/'], + $architecture->getLayers() + ); + $this->assertSame( + [realpath($directory . '/bar.php'), realpath($directory . '/foo.php'), realpath($directory . '/bar.php')], + $architecture->getImportedFiles() + ); + } + + public function testImportThrowsWhenFileDoesNotReturnCallable(): void + { + $path = $this->makeTemporaryFile('structarmed-import'); + file_put_contents($path, "expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Imported configuration [%s] must return a callable.', $path)); + + Architecture::define()->import($path); + } + + public function testImportThrowsOnCircularImport(): void + { + $directory = $this->makeTemporaryDirectory('structarmed-import'); + file_put_contents($directory . '/foo.php', <<<'PHP' + import(__DIR__ . '/bar.php'); + }; + + PHP); + file_put_contents($directory . '/bar.php', <<<'PHP' + import(__DIR__ . '/foo.php'); + }; + + PHP); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessageMatches('#^Circular import of configuration \[.+/foo\.php\]\.$#'); + + Architecture::define()->import($directory . '/foo.php'); + } } diff --git a/tests/Cache/AnalysisResultCacheTest.php b/tests/Cache/AnalysisResultCacheTest.php index cba0d7df..b0e25deb 100644 --- a/tests/Cache/AnalysisResultCacheTest.php +++ b/tests/Cache/AnalysisResultCacheTest.php @@ -2864,6 +2864,40 @@ public function testMetadataFileHashDoesNotChangeWhenOnlyFileMtimeChanges(): voi } } + public function testConfigHashChangesWhenImportedFileChanges(): void + { + $directory = $this->createTempDirectory(); + $config = $directory . '/structarmed.php'; + $imported = $directory . '/module.php'; + + file_put_contents($config, 'configHash($config, [$imported]); + + $this->assertNotSame( + (new AnalysisCacheMetadataFactory(new FileHashProvider()))->configHash($config, []), + $configHash + ); + + file_put_contents($imported, 'assertNotSame( + (new AnalysisCacheMetadataFactory(new FileHashProvider()))->configHash($config, [$imported]), + $configHash + ); + } finally { + foreach ([$config, $imported] as $file) { + if (file_exists($file)) { + unlink($file); + } + } + + $this->removeTempDirectory($directory); + } + } + public function testCacheMissesWhenComposerGeneratedVersionChanges(): void { $directory = $this->createTempDirectory(); diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 69d1ec9b..5efe797e 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -462,6 +462,141 @@ class Foo } } + public function testAnalyseCommandDetectsNestedImportedConfigChangesWithoutClearCache(): void + { + $basePath = $this->createTempDirectory(); + $progress = new class implements ProgressHandlerInterface { + public bool $started = false; + + public function start(int $total): void + { + $this->started = true; + } + + public function advance(string $file): void + { + } + + public function finish(): void + { + } + }; + mkdir($basePath . '/src'); + + file_put_contents($basePath . '/src/Foo.php', <<<'PHP' +layer('Source', []) + ->import(__DIR__ . '/foo.php'); +PHP); + + file_put_contents($basePath . '/foo.php', <<<'PHP' +import(__DIR__ . '/bar.php'); +}; +PHP); + + file_put_contents($basePath . '/bar.php', <<<'PHP' +runApplication( + [ + 'structarmed', + 'analyse', + 'src', + '--config=' . $basePath . '/structarmed.php', + '--clear-cache', + '--no-progress', + ], + $basePath + ); + + $this->assertSame(0, $firstExitCode, $firstOutput); + $this->assertStringContainsString('No violations found', $firstOutput); + + [$warmExitCode, $warmOutput] = $this->runAnalyseCommand( + ['src', '--config=' . $basePath . '/structarmed.php'], + $basePath, + $progress + ); + + $this->assertSame(0, $warmExitCode, $warmOutput); + $this->assertStringContainsString('No violations found', $warmOutput); + $this->assertFalse($progress->started); + + file_put_contents($basePath . '/bar.php', <<<'PHP' +rule('source.must_be_final', new MustBeFinalRule('Source')); +}; +PHP); + + [$secondExitCode, $secondOutput] = $this->runApplication( + [ + 'structarmed', + 'analyse', + 'src', + '--config=' . $basePath . '/structarmed.php', + '--no-progress', + ], + $basePath + ); + + $this->assertSame(1, $secondExitCode, $secondOutput); + $this->assertStringContainsString('Class [App\Foo] must be declared final', $secondOutput); + } finally { + unlink($basePath . '/foo.php'); + unlink($basePath . '/bar.php'); + $this->removeTempDirectory($basePath); + } + } + public function testAnalyseCommandResolvesProjectPathsAgainstBasePathOption(): void { $basePath = (string) realpath($this->createTempDirectory());