Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php

use Boundwize\StructArmed\Architecture;

return Architecture::define()
->import(__DIR__ . '/module/Foo/structarmed.php')
->import(__DIR__ . '/module/Bar/structarmed.php');
```

Each imported file returns a callable that receives the root `Architecture`:

```php
<?php

// module/Foo/structarmed.php
use Boundwize\StructArmed\Architecture;

return static function (Architecture $architecture): void {
$architecture->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.
Expand Down
61 changes: 61 additions & 0 deletions src/Architecture.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -112,6 +114,12 @@ final class Architecture

private ?string $baseline = null;

/** @var list<string> config files applied through import(), nested imports included */
private array $importedFiles = [];

/** @var array<string, true> config files whose import() is still running */
private array $importing = [];

private function __construct()
{
}
Expand All @@ -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
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -492,6 +547,12 @@ public function getBaseline(): ?string
return $this->baseline;
}

/** @return list<string> */
public function getImportedFiles(): array
{
return $this->importedFiles;
}

private function registerPendingSkip(string $skip): void
{
if (isset($this->rules[$skip])) {
Expand Down
11 changes: 11 additions & 0 deletions src/Cache/AnalysisCacheMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> $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
Expand Down
5 changes: 4 additions & 1 deletion src/Cli/AnalyseCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion src/PHPUnit/StructArmedExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
88 changes: 88 additions & 0 deletions tests/ArchitectureTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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'
<?php

use Boundwize\StructArmed\Architecture;

return static function (Architecture $architecture): void {
$architecture->layer('Bar', 'module/Bar/src/');
};

PHP);
file_put_contents($directory . '/foo.php', <<<'PHP'
<?php

use Boundwize\StructArmed\Architecture;

return static function (Architecture $architecture): void {
$architecture
->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, "<?php\n\nreturn [];\n");

$this->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'
<?php

use Boundwize\StructArmed\Architecture;

return static function (Architecture $architecture): void {
$architecture->import(__DIR__ . '/bar.php');
};

PHP);
file_put_contents($directory . '/bar.php', <<<'PHP'
<?php

use Boundwize\StructArmed\Architecture;

return static function (Architecture $architecture): void {
$architecture->import(__DIR__ . '/foo.php');
};

PHP);

$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessageMatches('#^Circular import of configuration \[.+/foo\.php\]\.$#');

Architecture::define()->import($directory . '/foo.php');
}
}
34 changes: 34 additions & 0 deletions tests/Cache/AnalysisResultCacheTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, '<?php return null;');
file_put_contents($imported, '<?php return null;');

try {
$configHash = (new AnalysisCacheMetadataFactory(new FileHashProvider()))->configHash($config, [$imported]);

$this->assertNotSame(
(new AnalysisCacheMetadataFactory(new FileHashProvider()))->configHash($config, []),
$configHash
);

file_put_contents($imported, '<?php return false;');

$this->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();
Expand Down
Loading
Loading