From 6fdbf83c281615df8b6256f3b9e614352b0f55dd Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 29 Oct 2026 21:25:25 +0700 Subject: [PATCH 1/5] feat: Add Architecture::import() to compose config from multiple files --- docs/configuration.md | 29 ++++++++++++ src/Architecture.php | 44 ++++++++++++++++++ src/Cache/AnalysisCacheMetadataFactory.php | 11 +++++ src/Cli/AnalyseCommand.php | 5 +- src/PHPUnit/StructArmedExtension.php | 5 +- tests/ArchitectureTest.php | 53 ++++++++++++++++++++++ tests/Cache/AnalysisResultCacheTest.php | 34 ++++++++++++++ 7 files changed, 179 insertions(+), 2 deletions(-) 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..3603bbb4 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,9 @@ final class Architecture private ?string $baseline = null; + /** @var list config files applied through import(), nested imports included */ + private array $importedFiles = []; + private function __construct() { } @@ -121,6 +126,39 @@ 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 + { + $configure = require $file; + + if (! is_callable($configure)) { + throw new InvalidArgumentException( + sprintf('Imported configuration [%s] must return a callable.', $file) + ); + } + + $configure($this); + + $this->importedFiles[] = realpath($file) ?: $file; + + return $this; + } + // ------------------------------------------------------------------------- // Layer definition // ------------------------------------------------------------------------- @@ -492,6 +530,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..cab662cc 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,52 @@ public function testSkipPathsForRulesetAcceptsStringAndArray(): void $this->assertSame(['tests/', 'fixtures/', 'stubs/'], $architecture->getRulesetSkipPaths()); } + + public function testImportAppliesReturnedCallableAndTracksNestedFiles(): void + { + $directory = (string) realpath($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); + + $architecture = Architecture::define() + ->layer('App', 'src/') + ->import($directory . '/foo.php'); + + $this->assertSame( + ['App' => 'src/', 'Foo' => 'module/Foo/src/', 'Bar' => 'module/Bar/src/'], + $architecture->getLayers() + ); + $this->assertSame([$directory . '/bar.php', $directory . '/foo.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); + } } 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(); From 97b23f87c9957e610e62aee8401d60d7e68dff47 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 29 Oct 2026 21:31:33 +0700 Subject: [PATCH 2/5] fix test on windows --- tests/ArchitectureTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/ArchitectureTest.php b/tests/ArchitectureTest.php index cab662cc..a2399d86 100644 --- a/tests/ArchitectureTest.php +++ b/tests/ArchitectureTest.php @@ -458,7 +458,7 @@ public function testSkipPathsForRulesetAcceptsStringAndArray(): void public function testImportAppliesReturnedCallableAndTracksNestedFiles(): void { - $directory = (string) realpath($this->makeTemporaryDirectory('structarmed-import')); + $directory = $this->makeTemporaryDirectory('structarmed-import'); file_put_contents($directory . '/bar.php', <<<'PHP' 'src/', 'Foo' => 'module/Foo/src/', 'Bar' => 'module/Bar/src/'], $architecture->getLayers() ); - $this->assertSame([$directory . '/bar.php', $directory . '/foo.php'], $architecture->getImportedFiles()); + $this->assertSame( + [realpath($directory . '/bar.php'), realpath($directory . '/foo.php')], + $architecture->getImportedFiles() + ); } public function testImportThrowsWhenFileDoesNotReturnCallable(): void From 9eb662a076882c04add480ff8dadda71b247ad97 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 29 Oct 2026 21:50:42 +0700 Subject: [PATCH 3/5] guard of circular import --- src/Architecture.php | 21 ++++- tests/ArchitectureTest.php | 36 +++++++- tests/Cli/StructArmedApplicationTest.php | 109 +++++++++++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) diff --git a/src/Architecture.php b/src/Architecture.php index 3603bbb4..aff87707 100644 --- a/src/Architecture.php +++ b/src/Architecture.php @@ -117,6 +117,9 @@ final class Architecture /** @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() { } @@ -152,9 +155,23 @@ public function import(string $file): self ); } - $configure($this); + $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)); + } + + $this->importing[$importedFile] = true; + + try { + $configure($this); + } finally { + unset($this->importing[$importedFile]); + } - $this->importedFiles[] = realpath($file) ?: $file; + $this->importedFiles[] = $importedFile; return $this; } diff --git a/tests/ArchitectureTest.php b/tests/ArchitectureTest.php index a2399d86..9147da8e 100644 --- a/tests/ArchitectureTest.php +++ b/tests/ArchitectureTest.php @@ -482,16 +482,18 @@ public function testImportAppliesReturnedCallableAndTracksNestedFiles(): void 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 . '/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'), realpath($directory . '/foo.php'), realpath($directory . '/bar.php')], $architecture->getImportedFiles() ); } @@ -506,4 +508,34 @@ public function testImportThrowsWhenFileDoesNotReturnCallable(): void 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/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 69d1ec9b..41435104 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -462,6 +462,115 @@ class Foo } } + public function testAnalyseCommandDetectsNestedImportedConfigChangesWithoutClearCache(): void + { + $basePath = $this->createTempDirectory(); + 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); + + 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()); From 2a73e637cc45a0819b77451908c4c5913bdcacfb Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 29 Oct 2026 21:54:46 +0700 Subject: [PATCH 4/5] move require --- src/Architecture.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/Architecture.php b/src/Architecture.php index aff87707..267bec8b 100644 --- a/src/Architecture.php +++ b/src/Architecture.php @@ -147,14 +147,6 @@ public static function define(): self */ public function import(string $file): self { - $configure = require $file; - - if (! is_callable($configure)) { - throw new InvalidArgumentException( - sprintf('Imported configuration [%s] must return a callable.', $file) - ); - } - $importedFile = realpath($file) ?: $file; // only imports still in progress are tracked, so a file imported twice @@ -163,6 +155,14 @@ public function import(string $file): self 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 { From af3750171eb0cf16ae1e42c2d638c1ba5b539487 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Thu, 29 Oct 2026 22:03:09 +0700 Subject: [PATCH 5/5] add test warm cache --- tests/Cli/StructArmedApplicationTest.php | 26 ++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/Cli/StructArmedApplicationTest.php b/tests/Cli/StructArmedApplicationTest.php index 41435104..5efe797e 100644 --- a/tests/Cli/StructArmedApplicationTest.php +++ b/tests/Cli/StructArmedApplicationTest.php @@ -465,6 +465,22 @@ 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' @@ -538,6 +554,16 @@ class Foo $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'