From 6edd11c30ab5c8c9d8b32e99963611c04b3f704b Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 19 Jul 2026 11:31:03 +0700 Subject: [PATCH 1/6] feat: add ExtendedClassAwareRuleInterface to detect extended classes --- docs/available-rules.md | 2 +- src/Analyser/Analyser.php | 41 +++++++++++++++++ src/Analyser/ClassNode.php | 10 +++++ src/Rule/ExtendedClassAwareRuleInterface.php | 18 ++++++++ src/Rule/Rules/Class_/MustBeFinalRule.php | 11 ++++- tests/Analyser/AnalyserTest.php | 46 ++++++++++++++++++++ tests/Rule/Class_/MustBeFinalRuleTest.php | 19 ++++++++ 7 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/Rule/ExtendedClassAwareRuleInterface.php diff --git a/docs/available-rules.md b/docs/available-rules.md index 49611b6..88bddfc 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -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`. | diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index db61b1a..1bdf00a 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -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; @@ -141,6 +142,10 @@ public function analyse( $classNodes = $extractionResult->classNodes; $classNodes = $this->withRecursiveParents($classNodes); + if ($this->hasExtendedClassAwareRule($classRules)) { + $this->markExtendedClasses($classNodes); + } + $fileAnalysisProvider = new FileAnalysisProvider($extractionResult->fileAnalyses); foreach ($fileAnalysisRules as $key => $rule) { @@ -646,6 +651,42 @@ private function dependenciesForInheritanceDependency( return $resolvedDependencies; } + /** + * @param array $classRules + */ + private function hasExtendedClassAwareRule(array $classRules): bool + { + foreach ($classRules as $classRule) { + if ($classRule instanceof ExtendedClassAwareRuleInterface) { + return true; + } + } + + return false; + } + + /** + * 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 $classNodes + */ + private function markExtendedClasses(array $classNodes): void + { + $extended = []; + + foreach ($classNodes as $classNode) { + foreach ($classNode->parentClasses as $parentClass) { + $extended[$parentClass] = true; + } + } + + foreach ($classNodes as $classNode) { + $classNode->setExtended(isset($extended[$classNode->className])); + } + } + /** * @param list $classNodes * @return list diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 5479b33..3d85f0f 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -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]); } @@ -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); diff --git a/src/Rule/ExtendedClassAwareRuleInterface.php b/src/Rule/ExtendedClassAwareRuleInterface.php new file mode 100644 index 0000000..76a1498 --- /dev/null +++ b/src/Rule/ExtendedClassAwareRuleInterface.php @@ -0,0 +1,18 @@ +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 +{ +} diff --git a/src/Rule/Rules/Class_/MustBeFinalRule.php b/src/Rule/Rules/Class_/MustBeFinalRule.php index 66b8237..a504b5f 100644 --- a/src/Rule/Rules/Class_/MustBeFinalRule.php +++ b/src/Rule/Rules/Class_/MustBeFinalRule.php @@ -5,6 +5,7 @@ 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; @@ -12,7 +13,9 @@ use function sprintf; -final readonly class MustBeFinalRule extends AbstractPhpParserFixableRule implements RuleInterface +final readonly class MustBeFinalRule extends AbstractPhpParserFixableRule implements + RuleInterface, + ExtendedClassAwareRuleInterface { public function __construct( private string $layer, @@ -43,6 +46,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', diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index 73398da..81a2d33 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -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' => ' ' '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 = 'makeTempProject([ + 'src/BaseHandler.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([ diff --git a/tests/Rule/Class_/MustBeFinalRuleTest.php b/tests/Rule/Class_/MustBeFinalRuleTest.php index 2acc1ed..97419c1 100644 --- a/tests/Rule/Class_/MustBeFinalRuleTest.php +++ b/tests/Rule/Class_/MustBeFinalRuleTest.php @@ -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; @@ -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, @@ -38,6 +40,7 @@ className: $className, isReadonly: false, isTrait: $isTrait, isEnum: $isEnum, + isExtended: $isExtended, ); } @@ -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')); From 9b41907a5d768c15d02683657e5172a6dd883852 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 19 Jul 2026 11:37:59 +0700 Subject: [PATCH 2/6] add more test --- tests/Analyser/ClassNodeTest.php | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/Analyser/ClassNodeTest.php b/tests/Analyser/ClassNodeTest.php index ca0f742..07bd620 100644 --- a/tests/Analyser/ClassNodeTest.php +++ b/tests/Analyser/ClassNodeTest.php @@ -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( From 5bcca4359fe5ebe6e1b9013db48e9defc3fd415d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 19 Jul 2026 11:39:40 +0700 Subject: [PATCH 3/6] direct extends RuleInterface --- src/Rule/ExtendedClassAwareRuleInterface.php | 2 +- src/Rule/Rules/Class_/MustBeFinalRule.php | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/Rule/ExtendedClassAwareRuleInterface.php b/src/Rule/ExtendedClassAwareRuleInterface.php index 76a1498..055e188 100644 --- a/src/Rule/ExtendedClassAwareRuleInterface.php +++ b/src/Rule/ExtendedClassAwareRuleInterface.php @@ -13,6 +13,6 @@ * 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 +interface ExtendedClassAwareRuleInterface extends RuleInterface { } diff --git a/src/Rule/Rules/Class_/MustBeFinalRule.php b/src/Rule/Rules/Class_/MustBeFinalRule.php index a504b5f..06329e1 100644 --- a/src/Rule/Rules/Class_/MustBeFinalRule.php +++ b/src/Rule/Rules/Class_/MustBeFinalRule.php @@ -8,14 +8,11 @@ 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, - ExtendedClassAwareRuleInterface +final readonly class MustBeFinalRule extends AbstractPhpParserFixableRule implements ExtendedClassAwareRuleInterface { public function __construct( private string $layer, From 1a62ad65edfa156439139ba46859ea600ef4cb12 Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 19 Jul 2026 11:50:02 +0700 Subject: [PATCH 4/6] reduce double loop --- src/Analyser/Analyser.php | 27 +++++++++------------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index 1bdf00a..e6e6829 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -86,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)) { @@ -99,6 +100,10 @@ public function analyse( $layerAwareRules[] = $rule; } + if ($rule instanceof ExtendedClassAwareRuleInterface) { + $hasExtendedClassAwareRule = true; + } + if (! $rule instanceof ProjectRuleInterface) { continue; } @@ -142,7 +147,7 @@ public function analyse( $classNodes = $extractionResult->classNodes; $classNodes = $this->withRecursiveParents($classNodes); - if ($this->hasExtendedClassAwareRule($classRules)) { + if ($hasExtendedClassAwareRule) { $this->markExtendedClasses($classNodes); } @@ -651,20 +656,6 @@ private function dependenciesForInheritanceDependency( return $resolvedDependencies; } - /** - * @param array $classRules - */ - private function hasExtendedClassAwareRule(array $classRules): bool - { - foreach ($classRules as $classRule) { - if ($classRule instanceof ExtendedClassAwareRuleInterface) { - return true; - } - } - - return false; - } - /** * Flag every class that another scanned class extends, using the recursive * parent chain resolved by {@see withRecursiveParents()}. A class name found From 8c4652578ba60c4ac51dd009511c960ba59cc69f Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 19 Jul 2026 11:55:35 +0700 Subject: [PATCH 5/6] update screenshot version --- docs/assets/no-violation.svg | 2 +- docs/assets/structarmed-showoff.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/assets/no-violation.svg b/docs/assets/no-violation.svg index a29441d..5f8c367 100644 --- a/docs/assets/no-violation.svg +++ b/docs/assets/no-violation.svg @@ -14,7 +14,7 @@ prj-ddd vendor/bin/structarmed analyze - StructArmed 0.14.20 — Architecture Enforcement + StructArmed 0.15.0 — Architecture Enforcement =============================================== diff --git a/docs/assets/structarmed-showoff.svg b/docs/assets/structarmed-showoff.svg index 57154c0..fefd68d 100644 --- a/docs/assets/structarmed-showoff.svg +++ b/docs/assets/structarmed-showoff.svg @@ -15,7 +15,7 @@ prj-ddd vendor/bin/structarmed analyze - StructArmed 0.14.20 — Architecture Enforcement + StructArmed 0.15.0 — Architecture Enforcement =============================================== From df9607a6b414bb1dfe7ffd5b4dffeb222b07459d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Sun, 19 Jul 2026 12:06:14 +0700 Subject: [PATCH 6/6] only mark setExtended when isset, which on classes --- src/Analyser/Analyser.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index e6e6829..5175c27 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -674,7 +674,11 @@ private function markExtendedClasses(array $classNodes): void } foreach ($classNodes as $classNode) { - $classNode->setExtended(isset($extended[$classNode->className])); + // 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); + } } }