From d5ad761f9c7a99023b5f9050aa9d62c0647df67f Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 4 Sep 2026 08:54:23 +0700 Subject: [PATCH 1/2] refactor LayerAwareRuleInterface into AbstractLayerAwareRule --- docs/custom-rules-and-presets.md | 51 +++++++++++++++++++++ src/Analyser/Analyser.php | 4 +- src/Rule/AbstractLayerAwareRule.php | 24 ++++++++++ src/Rule/LayerAwareRuleInterface.php | 13 ------ src/Rule/Rules/Layer/MayNotDependOnRule.php | 13 +----- tests/Rule/Layer/MayNotDependOnRuleTest.php | 6 +-- 6 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 src/Rule/AbstractLayerAwareRule.php delete mode 100644 src/Rule/LayerAwareRuleInterface.php diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 43b0515d..6840b9c3 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: + +```php +isInLayer('Controller'); + } + + public function evaluate(ClassNode $classNode): ?RuleViolation + { + foreach ($classNode->dependencies as $dependency) { + $dependencyNode = $this->classNodeMap[$dependency] ?? null; + + 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 and its `injectClassNodeMap()` setter, 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 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..46b58704 --- /dev/null +++ b/src/Rule/AbstractLayerAwareRule.php @@ -0,0 +1,24 @@ + class name → class node */ + protected array $classNodeMap = []; + + /** @param array $classNodeMap */ + public function injectClassNodeMap(array $classNodeMap): void + { + $this->classNodeMap = $classNodeMap; + } +} 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..b2af9772 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); 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') ); } From 5d7151963d840aa5f1eadf9ce38f4acf9d9ae89a Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Fri, 4 Sep 2026 08:58:09 +0700 Subject: [PATCH 2/2] add getDependencyNode() method --- docs/custom-rules-and-presets.md | 6 +++--- src/Rule/AbstractLayerAwareRule.php | 6 ++++++ src/Rule/Rules/Layer/MayNotDependOnRule.php | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 6840b9c3..531f04b1 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -179,7 +179,7 @@ Trade-off: only usage within the scanned paths is known. A class-like used solel ## 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: +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 dependencies as $dependency) { - $dependencyNode = $this->classNodeMap[$dependency] ?? null; + $dependencyNode = $this->getDependencyNode($dependency); if (! $dependencyNode instanceof ClassNode || $dependencyNode->isInLayer('Application')) { continue; @@ -223,7 +223,7 @@ final class ControllerMayOnlyDependOnApplicationRule extends AbstractLayerAwareR } ``` -The base class holds the property and its `injectClassNodeMap()` setter, 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 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. +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 diff --git a/src/Rule/AbstractLayerAwareRule.php b/src/Rule/AbstractLayerAwareRule.php index 46b58704..4faf2b40 100644 --- a/src/Rule/AbstractLayerAwareRule.php +++ b/src/Rule/AbstractLayerAwareRule.php @@ -21,4 +21,10 @@ 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/Rules/Layer/MayNotDependOnRule.php b/src/Rule/Rules/Layer/MayNotDependOnRule.php index b2af9772..d7eed8e1 100644 --- a/src/Rule/Rules/Layer/MayNotDependOnRule.php +++ b/src/Rule/Rules/Layer/MayNotDependOnRule.php @@ -73,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);