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
51 changes: 51 additions & 0 deletions docs/custom-rules-and-presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<?php

namespace App\Architecture\Rules;

use Boundwize\StructArmed\Analyser\ClassNode;
use Boundwize\StructArmed\Rule\AbstractLayerAwareRule;
use Boundwize\StructArmed\Rule\RuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

use function sprintf;

final class ControllerMayOnlyDependOnApplicationRule extends AbstractLayerAwareRule implements RuleInterface
{
public function appliesTo(ClassNode $classNode): bool
{
return $classNode->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:
Expand Down Expand Up @@ -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.
4 changes: 2 additions & 2 deletions src/Analyser/Analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@
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;
use Boundwize\StructArmed\Rule\ExtendedClassAwareRuleInterface;
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;
Expand Down Expand Up @@ -120,7 +120,7 @@ public function analyse(
$anonymousClassNodeRules[$key] = $rule;
}

if ($rule instanceof LayerAwareRuleInterface) {
if ($rule instanceof AbstractLayerAwareRule) {
$layerAwareRules[] = $rule;
}

Expand Down
30 changes: 30 additions & 0 deletions src/Rule/AbstractLayerAwareRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Boundwize\StructArmed\Rule;

use Boundwize\StructArmed\Analyser\ClassNode;

/**
* A rule that needs the layer of a class other than the one under evaluation,
* such as the layer a dependency belongs to. The analyser injects the scanned
* class node map before any class is evaluated.
*/
abstract class AbstractLayerAwareRule
{
/** @var array<string, ClassNode> class name → class node */
protected array $classNodeMap = [];

/** @param array<string, ClassNode> $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;
}
}
13 changes: 0 additions & 13 deletions src/Rule/LayerAwareRuleInterface.php

This file was deleted.

15 changes: 3 additions & 12 deletions src/Rule/Rules/Layer/MayNotDependOnRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<string, ClassNode> */
private array $classNodeMap = [];

public function __construct(
private readonly string $from,
private readonly string $to,
Expand All @@ -30,12 +27,6 @@ public function __construct(
$this->normalisedToPath = Path::normalise($toPath ?? $to);
}

/** @param array<string, ClassNode> $classNodeMap */
public function injectClassNodeMap(array $classNodeMap): void
{
$this->classNodeMap = $classNodeMap;
}

public function appliesTo(ClassNode $classNode): bool
{
return $classNode->isInLayer($this->from);
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 3 additions & 3 deletions tests/Rule/Layer/MayNotDependOnRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')
);
}
Expand Down