diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 43b0515d..531f04b1 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -177,6 +177,55 @@ The built-in [YAGNI preset](../presets/) rules follow this pattern: `MustBeUsedI Trade-off: only usage within the scanned paths is known. A class-like used solely by a consumer outside the scan — a vendor package, an unscanned directory, runtime-fed dynamic construction — is reported as if unused. Widen the scan, or use `skipRule()` and skip paths where such consumers exist. +## Reading Other Classes' Layers In A Custom Rule + +A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name, and `getDependencyNode()` looks a class up in it: + +```php +isInLayer('Controller'); + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + foreach ($classNode->dependencies as $dependency) { + $dependencyNode = $this->getDependencyNode($dependency); + + if (! $dependencyNode instanceof ClassNode || $dependencyNode->isInLayer('Application')) { + continue; + } + + return new RuleViolation( + message: sprintf('Controller [%s] must not depend on [%s]', $classNode->className, $dependency), + file: $classNode->file, + line: $classNode->line, + className: $classNode->className, + layer: $classNode->layer, + ); + } + + return null; + } +} +``` + +The base class holds the property, its `injectClassNodeMap()` setter, and the `getDependencyNode()` lookup, so the rule adds nothing but the check itself. A dependency outside the scanned paths — a vendor class, a PHP built-in — has no entry in the map, so `getDependencyNode()` returns null for it; fall back to path or namespace matching for those, or skip them as above. The built-in `MayNotDependOnRule` follows this pattern: it reads the dependency's layers from the map first and falls back to the `toPath` prefix only when the dependency was not scanned. + + ## Analysing Functions, Closures, And Anonymous Classes Named functions, closures, arrow functions, and anonymous classes are collected alongside named classes: @@ -497,6 +546,8 @@ Use `rule()` when one project needs one extra check. Use a custom `RuleInterface` class when the check itself is new behavior; add `FunctionRuleInterface` / `AnonymousFunctionRuleInterface` / `AnonymousClassRuleInterface` when it must also cover named functions, closures, or anonymous classes. +Extend `AbstractLayerAwareRule` when a rule must know the layer of a class other than the one under evaluation, such as the layer a dependency lives in. + Use a custom `PresetInterface` class when several layers and rules should be applied together or reused across repositories. Use `AbstractPhpParserFixableRule` with a `PhpParser\NodeVisitor` when a rule can rewrite the offending file; extend `AbstractTokenAwareVisitor` for that visitor when the fix targets punctuation or whitespace PHP-Parser records in no node. diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index c71bb41a..d5e73326 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -13,6 +13,7 @@ use Boundwize\StructArmed\File\SkipPathMatcher; use Boundwize\StructArmed\LayerResolver\ChainLayerResolver; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface; @@ -20,7 +21,6 @@ use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FixableInterface; use Boundwize\StructArmed\Rule\FunctionRuleInterface; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; use Boundwize\StructArmed\Rule\MultipleProjectRuleViolationInterface; use Boundwize\StructArmed\Rule\MultipleRuleViolationInterface; use Boundwize\StructArmed\Rule\ProjectRuleInterface; @@ -120,7 +120,7 @@ public function analyse( $anonymousClassNodeRules[$key] = $rule; } - if ($rule instanceof LayerAwareRuleInterface) { + if ($rule instanceof AbstractLayerAwareRule) { $layerAwareRules[] = $rule; } diff --git a/src/Rule/AbstractLayerAwareRule.php b/src/Rule/AbstractLayerAwareRule.php new file mode 100644 index 00000000..4faf2b40 --- /dev/null +++ b/src/Rule/AbstractLayerAwareRule.php @@ -0,0 +1,30 @@ + class name → class node */ + protected array $classNodeMap = []; + + /** @param array $classNodeMap */ + public function injectClassNodeMap(array $classNodeMap): void + { + $this->classNodeMap = $classNodeMap; + } + + /** The scanned node of a dependency, or null when it lies outside the scanned paths */ + protected function getDependencyNode(string $dependency): ?ClassNode + { + return $this->classNodeMap[$dependency] ?? null; + } +} diff --git a/src/Rule/LayerAwareRuleInterface.php b/src/Rule/LayerAwareRuleInterface.php deleted file mode 100644 index 6f275c05..00000000 --- a/src/Rule/LayerAwareRuleInterface.php +++ /dev/null @@ -1,13 +0,0 @@ - $classNodeMap class name → class node */ - public function injectClassNodeMap(array $classNodeMap): void; -} diff --git a/src/Rule/Rules/Layer/MayNotDependOnRule.php b/src/Rule/Rules/Layer/MayNotDependOnRule.php index ed1a91ed..d7eed8e1 100644 --- a/src/Rule/Rules/Layer/MayNotDependOnRule.php +++ b/src/Rule/Rules/Layer/MayNotDependOnRule.php @@ -5,7 +5,7 @@ namespace Boundwize\StructArmed\Rule\Rules\Layer; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; use Boundwize\StructArmed\Rule\MultipleRuleViolationInterface; use Boundwize\StructArmed\Rule\RuleViolation; use Boundwize\StructArmed\Util\Path; @@ -15,13 +15,10 @@ use function str_contains; use function str_starts_with; -final class MayNotDependOnRule implements MultipleRuleViolationInterface, LayerAwareRuleInterface +final class MayNotDependOnRule extends AbstractLayerAwareRule implements MultipleRuleViolationInterface { private readonly string $normalisedToPath; - /** @var array */ - private array $classNodeMap = []; - public function __construct( private readonly string $from, private readonly string $to, @@ -30,12 +27,6 @@ public function __construct( $this->normalisedToPath = Path::normalise($toPath ?? $to); } - /** @param array $classNodeMap */ - public function injectClassNodeMap(array $classNodeMap): void - { - $this->classNodeMap = $classNodeMap; - } - public function appliesTo(ClassNode $classNode): bool { return $classNode->isInLayer($this->from); @@ -82,7 +73,7 @@ className: $classNode->className, private function isInForbiddenLayer(string $dependency): bool { // Priority 1: Use the scanned dependency node if available - $dependencyNode = $this->classNodeMap[$dependency] ?? null; + $dependencyNode = $this->getDependencyNode($dependency); if ($dependencyNode instanceof ClassNode && $dependencyNode->layers !== []) { return in_array($this->to, $dependencyNode->layers, true); diff --git a/tests/Rule/Layer/MayNotDependOnRuleTest.php b/tests/Rule/Layer/MayNotDependOnRuleTest.php index 8d8671b2..12d1380b 100644 --- a/tests/Rule/Layer/MayNotDependOnRuleTest.php +++ b/tests/Rule/Layer/MayNotDependOnRuleTest.php @@ -5,7 +5,7 @@ namespace Boundwize\StructArmed\Tests\Rule\Layer; use Boundwize\StructArmed\Analyser\ClassNode; -use Boundwize\StructArmed\Rule\LayerAwareRuleInterface; +use Boundwize\StructArmed\Rule\AbstractLayerAwareRule; use Boundwize\StructArmed\Rule\Rules\Layer\MayNotDependOnRule; use Boundwize\StructArmed\Rule\RuleViolation; use PHPUnit\Framework\Attributes\CoversClass; @@ -164,10 +164,10 @@ public function testReportsMultipleViolationsWhenMultipleForbiddenDependencies() $this->assertStringContainsString('App\Infrastructure\B', $violations[1]->message); } - public function testImplementsLayerAwareRuleInterface(): void + public function testExtendsAbstractLayerAwareRule(): void { $this->assertInstanceOf( - LayerAwareRuleInterface::class, + AbstractLayerAwareRule::class, new MayNotDependOnRule(from: 'Domain', to: 'Infrastructure') ); }