From 021f39221adab13eecb59c8314a0650328a59e3d Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 18 Aug 2026 23:22:34 +0700 Subject: [PATCH 1/2] fix: Case-insensitive __construct/__destruct method name in MethodNode and ClassNode --- src/Analyser/ClassNode.php | 2 +- src/Analyser/MethodNode.php | 6 ++++-- tests/Analyser/ClassNodeTest.php | 20 +++++++++++++++++++ tests/Analyser/MethodNodeTest.php | 15 ++++++++++++++ .../Method/MustHaveReturnTypeRuleTest.php | 12 +++++++++++ 5 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index 7c33d4d2..f4b88c74 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -204,7 +204,7 @@ public function accessesSuperglobals(): bool public function constructorParamCount(): int { foreach ($this->methods as $method) { - if ($method->name === '__construct') { + if ($method->isConstructor()) { return $method->paramCount; } } diff --git a/src/Analyser/MethodNode.php b/src/Analyser/MethodNode.php index 071d8a93..6b3ad39a 100644 --- a/src/Analyser/MethodNode.php +++ b/src/Analyser/MethodNode.php @@ -4,6 +4,8 @@ namespace Boundwize\StructArmed\Analyser; +use function strcasecmp; + final readonly class MethodNode { public function __construct( @@ -27,11 +29,11 @@ public function isPublic(): bool public function isConstructor(): bool { - return $this->name === '__construct'; + return strcasecmp($this->name, '__construct') === 0; } public function isDestructor(): bool { - return $this->name === '__destruct'; + return strcasecmp($this->name, '__destruct') === 0; } } diff --git a/tests/Analyser/ClassNodeTest.php b/tests/Analyser/ClassNodeTest.php index 5a1bd6aa..17eb053b 100644 --- a/tests/Analyser/ClassNodeTest.php +++ b/tests/Analyser/ClassNodeTest.php @@ -400,4 +400,24 @@ className: 'App\\Domain\\Order', $this->assertSame(2, $withConstructor->constructorParamCount()); $this->assertSame(0, $withoutConstructor->constructorParamCount()); } + + public function testConstructorParamCountMatchesConstructorCaseInsensitively(): void + { + $classNode = new ClassNode( + className: 'App\\Domain\\Order', + file: '/src/Order.php', + line: 1, + layer: 'Domain', + extends: null, + isAbstract: false, + isFinal: true, + isInterface: false, + isReadonly: false, + methods: [ + new MethodNode('__CONSTRUCT', 'public', false, false, 3, 1, 3), + ], + ); + + $this->assertSame(3, $classNode->constructorParamCount()); + } } diff --git a/tests/Analyser/MethodNodeTest.php b/tests/Analyser/MethodNodeTest.php index 0654d764..d583ee32 100644 --- a/tests/Analyser/MethodNodeTest.php +++ b/tests/Analyser/MethodNodeTest.php @@ -28,4 +28,19 @@ public function testMethodHelpers(): void $this->assertFalse($protectedMethod->isConstructor()); $this->assertFalse($protectedMethod->isDestructor()); } + + public function testConstructorAndDestructorDetectionIsCaseInsensitive(): void + { + $upperConstructor = new MethodNode('__CONSTRUCT', 'public', false, false, 0, 1, 3, isMagic: true); + $mixedConstructor = new MethodNode('__Construct', 'public', false, false, 0, 1, 3, isMagic: true); + $upperDestructor = new MethodNode('__DESTRUCT', 'public', false, false, 0, 1, 3, isMagic: true); + $mixedDestructor = new MethodNode('__Destruct', 'public', false, false, 0, 1, 3, isMagic: true); + + $this->assertTrue($upperConstructor->isConstructor()); + $this->assertTrue($mixedConstructor->isConstructor()); + $this->assertFalse($upperConstructor->isDestructor()); + $this->assertTrue($upperDestructor->isDestructor()); + $this->assertTrue($mixedDestructor->isDestructor()); + $this->assertFalse($upperDestructor->isConstructor()); + } } diff --git a/tests/Rule/Method/MustHaveReturnTypeRuleTest.php b/tests/Rule/Method/MustHaveReturnTypeRuleTest.php index 8174f79c..e5f7d581 100644 --- a/tests/Rule/Method/MustHaveReturnTypeRuleTest.php +++ b/tests/Rule/Method/MustHaveReturnTypeRuleTest.php @@ -92,6 +92,18 @@ public function testIgnoresDestructor(): void $this->assertNotInstanceOf(RuleViolation::class, $mustHaveReturnTypeRule->evaluate($classNode)); } + public function testIgnoresConstructorAndDestructorRegardlessOfCase(): void + { + $mustHaveReturnTypeRule = new MustHaveReturnTypeRule(layer: 'Domain'); + $classNode = $this->makeNode([ + $this->method('__CONSTRUCT', hasReturnType: false), + $this->method('__Destruct', hasReturnType: false), + ]); + + // PHP method names are case-insensitive, so these are still ctor/dtor + $this->assertNotInstanceOf(RuleViolation::class, $mustHaveReturnTypeRule->evaluate($classNode)); + } + public function testIgnoresPrivateMethods(): void { $mustHaveReturnTypeRule = new MustHaveReturnTypeRule(layer: 'Domain'); From 16f1728029ec69399f6913d9c76c1cd25a45b99c Mon Sep 17 00:00:00 2001 From: Abdul Malik Ikhsan Date: Tue, 18 Aug 2026 23:22:44 +0700 Subject: [PATCH 2/2] add --- .../MustHaveReturnTypeRuleFunctionalTest.php | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/Rule/Method/MustHaveReturnTypeRuleFunctionalTest.php diff --git a/tests/Rule/Method/MustHaveReturnTypeRuleFunctionalTest.php b/tests/Rule/Method/MustHaveReturnTypeRuleFunctionalTest.php new file mode 100644 index 00000000..3fa32c3d --- /dev/null +++ b/tests/Rule/Method/MustHaveReturnTypeRuleFunctionalTest.php @@ -0,0 +1,117 @@ +makeTempProject([ + 'src/Domain/OrderService.php' => <<<'PHP' + analyse($basePath)->forRule('domain.must_have_return_type'); + + $this->assertCount(1, $violations); + $this->assertStringContainsString('App\Domain\OrderService::process()', $violations[0]->message); + } + + public function testIgnoresConstructorAndDestructorDeclaredWithDifferentCase(): void + { + // PHP method names are case-insensitive: __CONSTRUCT()/__Destruct() are + // still ctor/dtor and may not declare a return type, so no violation. + $basePath = $this->makeTempProject([ + 'src/Domain/PaymentService.php' => <<<'PHP' + analyse($basePath)->forRule('domain.must_have_return_type'); + + $this->assertCount(0, $violations); + } + + private function analyse(string $basePath): RuleViolationCollection + { + $architecture = Architecture::define() + ->layer('Domain', 'src/Domain/') + ->rule('domain.must_have_return_type', new MustHaveReturnTypeRule('Domain')); + + return (new Analyser($basePath)) + ->analyse($architecture, [], null, AnalyserOptions::sequential()); + } + + /** @param array $files */ + private function makeTempProject(array $files): string + { + $basePath = $this->makeTemporaryDirectory('structarmed-must-have-return-type'); + + foreach ($files as $file => $contents) { + $path = $basePath . '/' . $file; + + if (! is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + + file_put_contents($path, $contents); + } + + return $basePath; + } +}