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
2 changes: 1 addition & 1 deletion docs/assets/no-violation.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/assets/structarmed-showoff.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion docs/available-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`.
| `ClassNameMustNotHavePrefixRule` | `new ClassNameMustNotHavePrefixRule(layer: 'Model', prefix: 'Model')` | Classes in a layer do not use a forbidden prefix. |
| `MaxDependencyCountRule` | `new MaxDependencyCountRule(layer: 'Controller', maxCount: 5)` | Constructor dependency count stays below the configured limit. |
| `MayNotImplementInterfaceRule` | `new MayNotImplementInterfaceRule(layer: 'Domain', interface: JsonSerializable::class)` | Classes in a layer do not implement a forbidden interface. |
| `MustBeFinalRule` | `new MustBeFinalRule(layer: 'Domain', classNamePattern: '/Entity$/')` | Matching classes in a layer are declared `final`. Supports `--fix`. |
| `MustBeFinalRule` | `new MustBeFinalRule(layer: 'Domain', classNamePattern: '/Entity$/')` | Matching classes in a layer are declared `final`. Classes extended by another scanned class are skipped (making them `final` would break the child). Supports `--fix`. |
| `MustBeInterfaceRule` | `new MustBeInterfaceRule(layer: 'Contract', classNamePattern: '/Interface$/')` | Matching declarations in a layer are interfaces. |
| `MustDeclareConstantVisibilityRule` | `new MustDeclareConstantVisibilityRule(layer: 'Source')` | Class constants declare `public`, `protected`, or `private`. Supports `--fix`. |
| `MustDeclareMethodVisibilityRule` | `new MustDeclareMethodVisibilityRule(layer: 'Source')` | Methods declare `public`, `protected`, or `private`. Supports `--fix`. |
Expand Down
42 changes: 39 additions & 3 deletions src/Analyser/Analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Boundwize\StructArmed\LayerResolver\ChainLayerResolver;
use Boundwize\StructArmed\Progress\ProgressHandlerInterface;
use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface;
use Boundwize\StructArmed\Rule\ExtendedClassAwareRuleInterface;
use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface;
use Boundwize\StructArmed\Rule\FixableInterface;
use Boundwize\StructArmed\Rule\LayerAwareRuleInterface;
Expand Down Expand Up @@ -85,9 +86,10 @@ public function analyse(
$classRules = $this->classRules($rules, $skippedRuleKeys);
$ruleSkipMatchers = $this->ruleSkipMatchers($classRules, $globalSkipPaths, $ruleSkipPaths);

$projectRuleViolations = [];
$fileAnalysisRules = [];
$layerAwareRules = [];
$projectRuleViolations = [];
$fileAnalysisRules = [];
$layerAwareRules = [];
$hasExtendedClassAwareRule = false;

foreach ($rules as $key => $rule) {
if (array_key_exists($key, $skippedRuleKeys)) {
Expand All @@ -98,6 +100,10 @@ public function analyse(
$layerAwareRules[] = $rule;
}

if ($rule instanceof ExtendedClassAwareRuleInterface) {
$hasExtendedClassAwareRule = true;
}

if (! $rule instanceof ProjectRuleInterface) {
continue;
}
Expand Down Expand Up @@ -141,6 +147,10 @@ public function analyse(
$classNodes = $extractionResult->classNodes;
$classNodes = $this->withRecursiveParents($classNodes);

if ($hasExtendedClassAwareRule) {
$this->markExtendedClasses($classNodes);
}

$fileAnalysisProvider = new FileAnalysisProvider($extractionResult->fileAnalyses);

foreach ($fileAnalysisRules as $key => $rule) {
Expand Down Expand Up @@ -646,6 +656,32 @@ private function dependenciesForInheritanceDependency(
return $resolvedDependencies;
}

/**
* Flag every class that another scanned class extends, using the recursive
* parent chain resolved by {@see withRecursiveParents()}. A class name found
* among any node's parent classes is extended within the scanned paths.
*
* @param list<ClassNode> $classNodes
*/
private function markExtendedClasses(array $classNodes): void
{
$extended = [];

foreach ($classNodes as $classNode) {
foreach ($classNode->parentClasses as $parentClass) {
$extended[$parentClass] = true;
}
}

foreach ($classNodes as $classNode) {
// Only classes appear in parentClasses; traits, interfaces, and enums
// never do, so they are left with the default (not extended).
if (isset($extended[$classNode->className])) {
$classNode->setExtended(true);
}
}
}

/**
* @param list<ClassNode> $classNodes
* @return list<ClassNode>
Expand Down
10 changes: 10 additions & 0 deletions src/Analyser/ClassNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ public function __construct(
public readonly array $interfaceExtends = [],
public array $parentClasses = [],
public array $parentInterfaces = [],
public bool $isExtended = false,
) {
$this->layers = $layers ?: array_filter([$this->layer]);
}
Expand All @@ -72,6 +73,15 @@ public function setRecursiveParents(array $parentClasses, array $parentInterface
$this->parentInterfaces = $parentInterfaces;
}

/**
* Whether another scanned class extends this class. Computed by the analyser
* for rules implementing ExtendedClassAwareRuleInterface; false otherwise.
*/
public function setExtended(bool $isExtended): void
{
$this->isExtended = $isExtended;
}

public function shortName(): string
{
$parts = explode('\\', $this->className);
Expand Down
18 changes: 18 additions & 0 deletions src/Rule/ExtendedClassAwareRuleInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Boundwize\StructArmed\Rule;

/**
* Marker for rules whose evaluation depends on whether a class is extended by
* another scanned class. When at least one active rule implements this marker,
* the analyser flags each ClassNode's $isExtended (from its recursive parents)
* before rules are evaluated, so implementers can read $classNode->isExtended.
*
* Trade-off: only classes extended within the scanned paths are known. A class
* extended solely by a consumer outside the scan is reported as if not extended.
*/
interface ExtendedClassAwareRuleInterface extends RuleInterface
{
}
10 changes: 8 additions & 2 deletions src/Rule/Rules/Class_/MustBeFinalRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
namespace Boundwize\StructArmed\Rule\Rules\Class_;

use Boundwize\StructArmed\Analyser\ClassNode;
use Boundwize\StructArmed\Rule\ExtendedClassAwareRuleInterface;
use Boundwize\StructArmed\Rule\Fixer\PhpParser\AbstractPhpParserFixableRule;
use Boundwize\StructArmed\Rule\Fixer\PhpParser\Class_\AddFinalClassVisitor;
use Boundwize\StructArmed\Rule\RuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

use function sprintf;

final readonly class MustBeFinalRule extends AbstractPhpParserFixableRule implements RuleInterface
final readonly class MustBeFinalRule extends AbstractPhpParserFixableRule implements ExtendedClassAwareRuleInterface
{
public function __construct(
private string $layer,
Expand Down Expand Up @@ -43,6 +43,12 @@ public function evaluate(ClassNode $classNode): ?RuleViolation
return null;
}

// A class another scanned class extends cannot be made final; forcing it
// would break the child, so treat it as legitimately non-final.
if ($classNode->isExtended) {
return null;
}

return new RuleViolation(
message: sprintf(
'Class [%s] must be declared final',
Expand Down
46 changes: 46 additions & 0 deletions tests/Analyser/AnalyserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,52 @@ public function testAnalyserCollectsClassNodesWithSequentialRunner(): void
$this->assertCount(2, $ruleViolationCollection->forRule('source.must_be_final'));
}

public function testMustBeFinalRuleDoesNotFlagClassExtendedByAnotherScannedClass(): void
{
$basePath = $this->makeTempProject([
'src/BaseHandler.php' => '<?php namespace App; class BaseHandler {}',
'src/OrderHandler.php' => '<?php namespace App; final class OrderHandler extends BaseHandler {}',
'src/PaymentHandler.php' => '<?php namespace App; class PaymentHandler {}',
]);

$architecture = Architecture::define()
->layer('Source', 'src/')
->rule('source.must_be_final', new MustBeFinalRule('Source'));

$violations = (new Analyser($basePath))
->analyse($architecture, [], null, AnalyserOptions::sequential())
->forRule('source.must_be_final');

// BaseHandler is extended (must stay non-final); PaymentHandler is the
// only genuinely non-final leaf class.
$this->assertCount(1, $violations);
$this->assertSame('App\PaymentHandler', $violations[0]->className);
}

public function testMustBeFinalRuleFlagsExtendedClassWhenChildIsOutsideScannedPaths(): void
{
$order = '<?php namespace Consumer; use App\BaseHandler;' . "\n"
. 'final class OrderHandler extends BaseHandler {}';

$basePath = $this->makeTempProject([
'src/BaseHandler.php' => '<?php namespace App; class BaseHandler {}',
'consumer/OrderHandler.php' => $order,
]);

$architecture = Architecture::define()
->layer('Source', 'src/')
->rule('source.must_be_final', new MustBeFinalRule('Source'));

$violations = (new Analyser($basePath))
->analyse($architecture, ['src/'], null, AnalyserOptions::sequential())
->forRule('source.must_be_final');

// The extending class lives outside the scanned paths, so BaseHandler is
// reported as if not extended — a false positive on purpose.
$this->assertCount(1, $violations);
$this->assertSame('App\BaseHandler', $violations[0]->className);
}

public function testAnalyserMarksFixableRuleViolations(): void
{
$basePath = $this->makeTempProject([
Expand Down
23 changes: 23 additions & 0 deletions tests/Analyser/ClassNodeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,29 @@ className: 'App\\Domain\\OrderService',
$this->assertSame(['App\\Contracts\\OrderService'], $classNode->parentInterfaces);
}

public function testSetExtendedTogglesIsExtendedFlag(): void
{
$classNode = new ClassNode(
className: 'App\\Domain\\OrderService',
file: '/src/OrderService.php',
line: 5,
layer: 'Domain',
extends: null,
isAbstract: false,
isFinal: false,
isInterface: false,
isReadonly: false,
);

$this->assertFalse($classNode->isExtended);

$classNode->setExtended(true);
$this->assertTrue($classNode->isExtended);

$classNode->setExtended(false);
$this->assertFalse($classNode->isExtended);
}

public function testDependsOnMatchesExistingClassesExactly(): void
{
$classNode = new ClassNode(
Expand Down
19 changes: 19 additions & 0 deletions tests/Rule/Class_/MustBeFinalRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

use Boundwize\StructArmed\Analyser\ClassNode;
use Boundwize\StructArmed\Preset\Preset;
use Boundwize\StructArmed\Rule\ExtendedClassAwareRuleInterface;
use Boundwize\StructArmed\Rule\FixableInterface;
use Boundwize\StructArmed\Rule\Fixer\PhpParser\Class_\AddFinalClassVisitor;
use Boundwize\StructArmed\Rule\Rules\Class_\MustBeFinalRule;
Expand All @@ -25,6 +26,7 @@ private function makeNode(
bool $isInterface = false,
bool $isTrait = false,
bool $isEnum = false,
bool $isExtended = false,
): ClassNode {
return new ClassNode(
className: $className,
Expand All @@ -38,6 +40,7 @@ className: $className,
isReadonly: false,
isTrait: $isTrait,
isEnum: $isEnum,
isExtended: $isExtended,
);
}

Expand All @@ -60,6 +63,22 @@ public function testViolatesWhenClassIsNotFinal(): void
$this->assertStringContainsString('final', $violation->message);
}

public function testPassesWhenClassIsExtendedByAnotherClass(): void
{
$mustBeFinalRule = new MustBeFinalRule(layer: 'Domain');
$classNode = $this->makeNode(isFinal: false, isExtended: true);

$this->assertNotInstanceOf(RuleViolation::class, $mustBeFinalRule->evaluate($classNode));
}

public function testIsExtendedClassAware(): void
{
$this->assertInstanceOf(
ExtendedClassAwareRuleInterface::class,
new MustBeFinalRule(layer: 'Domain')
);
}

public function testIsFixable(): void
{
$this->assertInstanceOf(FixableInterface::class, new MustBeFinalRule(layer: 'Domain'));
Expand Down
Loading