Skip to content

Commit b80c4ba

Browse files
committed
Use reflection to extract inherited interfaces and parent classes
In the FileVisitor, after parsing direct interfaces and parent classes from the AST, use PHP reflection to resolve the full inheritance chain. This allows rules like Implement and Extend to work across the entire hierarchy, not just direct declarations. For example, if AbstractType implements FormTypeInterface and MyFormType extends AbstractType, the Implement('FormTypeInterface') rule will now correctly match MyFormType. The reflection-based resolution is applied to: - Classes: inherited interfaces from parent classes and parent interfaces - Enums: parent interfaces of directly implemented interfaces - Interfaces: ancestor interfaces of directly extended interfaces Reflected members are added without creating dependency entries, keeping the dependency tracking accurate to what is directly referenced in source. Closes #169 https://claude.ai/code/session_01Ko24Jtok9YQagSFair7zQh
1 parent 415aa56 commit b80c4ba

File tree

5 files changed

+535
-0
lines changed

5 files changed

+535
-0
lines changed

src/Analyzer/ClassDescriptionBuilder.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,26 @@ public function addInterface(string $FQCN, int $line): self
8080
return $this;
8181
}
8282

83+
/**
84+
* Add an interface discovered via reflection (inherited from parent class or parent interface).
85+
* Unlike addInterface(), this does not add a dependency since the interface is not directly
86+
* referenced in the source file.
87+
*/
88+
public function addReflectedInterface(string $FQCN): self
89+
{
90+
$fqcn = FullyQualifiedClassName::fromString($FQCN);
91+
92+
foreach ($this->interfaces as $existing) {
93+
if ($existing->toString() === $fqcn->toString()) {
94+
return $this;
95+
}
96+
}
97+
98+
$this->interfaces[] = $fqcn;
99+
100+
return $this;
101+
}
102+
83103
public function addDependency(ClassDependency $cd): self
84104
{
85105
if ($this->isPhpCoreClass($cd)) {
@@ -99,6 +119,26 @@ public function addExtends(string $FQCN, int $line): self
99119
return $this;
100120
}
101121

122+
/**
123+
* Add a parent class discovered via reflection (ancestor beyond the direct parent).
124+
* Unlike addExtends(), this does not add a dependency since the ancestor is not directly
125+
* referenced in the source file.
126+
*/
127+
public function addReflectedExtends(string $FQCN): self
128+
{
129+
$fqcn = FullyQualifiedClassName::fromString($FQCN);
130+
131+
foreach ($this->extends as $existing) {
132+
if ($existing->toString() === $fqcn->toString()) {
133+
return $this;
134+
}
135+
}
136+
137+
$this->extends[] = $fqcn;
138+
139+
return $this;
140+
}
141+
102142
public function setFinal(bool $final): self
103143
{
104144
$this->final = $final;

src/Analyzer/FileVisitor.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,8 @@ private function handleClassNode(Node $node): void
137137
->addExtends($node->extends->toString(), $node->getLine());
138138
}
139139

140+
$this->resolveInheritedInterfacesAndExtends($node);
141+
140142
$this->classDescriptionBuilder->setFinal($node->isFinal());
141143

142144
$this->classDescriptionBuilder->setReadonly($node->isReadonly());
@@ -182,6 +184,11 @@ private function handleEnumNode(Node $node): void
182184
$this->classDescriptionBuilder
183185
->addInterface($interface->toString(), $interface->getLine());
184186
}
187+
188+
// Resolve inherited interfaces from directly implemented interfaces
189+
foreach ($node->implements as $interface) {
190+
$this->addReflectedInterfaceParents($interface->toString());
191+
}
185192
}
186193

187194
private function handleStaticClassConstantNode(Node $node): void
@@ -295,6 +302,11 @@ private function handleInterfaceNode(Node $node): void
295302
$this->classDescriptionBuilder
296303
->addExtends($interface->toString(), $interface->getLine());
297304
}
305+
306+
// Resolve ancestor interfaces from directly extended interfaces
307+
foreach ($node->extends as $interface) {
308+
$this->addReflectedExtendedInterfaceParents($interface->toString());
309+
}
298310
}
299311

300312
private function handleTraitNode(Node $node): void
@@ -395,4 +407,78 @@ private function handlePropertyHookNode(Node $node): void
395407
$this->addParamDependency($param);
396408
}
397409
}
410+
411+
/**
412+
* Use reflection to resolve interfaces and parent classes from the full inheritance chain.
413+
* This enriches the ClassDescription with interfaces inherited from parent classes
414+
* and parent interfaces, so that rules like Implement and Extend work across
415+
* the entire hierarchy.
416+
*/
417+
private function resolveInheritedInterfacesAndExtends(Node\Stmt\Class_ $node): void
418+
{
419+
// Resolve inherited interfaces from directly implemented interfaces
420+
foreach ($node->implements as $interface) {
421+
$this->addReflectedInterfaceParents($interface->toString());
422+
}
423+
424+
// Resolve inherited interfaces and ancestor classes from parent class
425+
if (null === $node->extends) {
426+
return;
427+
}
428+
429+
$parentClassName = $node->extends->toString();
430+
431+
try {
432+
/** @var class-string $parentClassName */
433+
$reflection = new \ReflectionClass($parentClassName);
434+
435+
foreach ($reflection->getInterfaceNames() as $interfaceName) {
436+
$this->classDescriptionBuilder->addReflectedInterface($interfaceName);
437+
}
438+
439+
$ancestor = $reflection->getParentClass();
440+
while (false !== $ancestor) {
441+
$this->classDescriptionBuilder->addReflectedExtends($ancestor->getName());
442+
$ancestor = $ancestor->getParentClass();
443+
}
444+
} catch (\ReflectionException $e) {
445+
// Parent class not autoloadable, skip reflection-based resolution
446+
}
447+
}
448+
449+
/**
450+
* Use reflection to discover parent interfaces of a given interface,
451+
* adding them to the interfaces list (for classes and enums).
452+
*/
453+
private function addReflectedInterfaceParents(string $interfaceName): void
454+
{
455+
try {
456+
/** @var class-string $interfaceName */
457+
$reflection = new \ReflectionClass($interfaceName);
458+
459+
foreach ($reflection->getInterfaceNames() as $parentInterfaceName) {
460+
$this->classDescriptionBuilder->addReflectedInterface($parentInterfaceName);
461+
}
462+
} catch (\ReflectionException $e) {
463+
// Interface not autoloadable, skip
464+
}
465+
}
466+
467+
/**
468+
* Use reflection to discover parent interfaces of a given interface,
469+
* adding them to the extends list (for interface definitions).
470+
*/
471+
private function addReflectedExtendedInterfaceParents(string $interfaceName): void
472+
{
473+
try {
474+
/** @var class-string $interfaceName */
475+
$reflection = new \ReflectionClass($interfaceName);
476+
477+
foreach ($reflection->getInterfaceNames() as $parentInterfaceName) {
478+
$this->classDescriptionBuilder->addReflectedExtends($parentInterfaceName);
479+
}
480+
} catch (\ReflectionException $e) {
481+
// Interface not autoloadable, skip
482+
}
483+
}
398484
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Arkitect\Tests\Integration;
6+
7+
use Arkitect\Expression\ForClasses\Extend;
8+
use Arkitect\Expression\ForClasses\HaveNameMatching;
9+
use Arkitect\Expression\ForClasses\Implement;
10+
use Arkitect\Rules\Rule;
11+
use Arkitect\Tests\Utils\TestRunner;
12+
use org\bovigo\vfs\vfsStream;
13+
use PHPUnit\Framework\TestCase;
14+
15+
class InheritedInterfacesTest extends TestCase
16+
{
17+
public function test_implement_rule_matches_inherited_interfaces(): void
18+
{
19+
$dir = vfsStream::setup('root', null, $this->createDirStructure())->url();
20+
21+
$runner = TestRunner::create('8.2');
22+
23+
// Countable is inherited from ArrayObject, not directly implemented by MyCollection
24+
$rule = Rule::allClasses()
25+
->that(new Implement('Countable'))
26+
->should(new HaveNameMatching('*Collection'))
27+
->because('classes implementing Countable should be named *Collection');
28+
29+
$runner->run($dir, $rule);
30+
31+
self::assertCount(0, $runner->getParsingErrors());
32+
33+
// MyCollection extends ArrayObject which implements Countable
34+
// So MyCollection should match the rule and have no violations
35+
// (it implements Countable indirectly and is named *Collection)
36+
self::assertCount(0, $runner->getViolations());
37+
}
38+
39+
public function test_extend_rule_matches_ancestor_classes(): void
40+
{
41+
$dir = vfsStream::setup('root', null, $this->createExtendsStructure())->url();
42+
43+
$runner = TestRunner::create('8.2');
44+
45+
// LogicException is a grandparent of MyException (via InvalidArgumentException)
46+
$rule = Rule::allClasses()
47+
->that(new Extend('LogicException'))
48+
->should(new HaveNameMatching('*Exception'))
49+
->because('classes extending LogicException should be named *Exception');
50+
51+
$runner->run($dir, $rule);
52+
53+
self::assertCount(0, $runner->getParsingErrors());
54+
self::assertCount(0, $runner->getViolations());
55+
}
56+
57+
public function createDirStructure(): array
58+
{
59+
return [
60+
'App' => [
61+
'MyCollection.php' => '<?php
62+
63+
namespace App;
64+
65+
class MyCollection extends \ArrayObject {
66+
public function customMethod(): void {}
67+
}
68+
',
69+
],
70+
];
71+
}
72+
73+
public function createExtendsStructure(): array
74+
{
75+
return [
76+
'App' => [
77+
'MyException.php' => '<?php
78+
79+
namespace App;
80+
81+
class MyException extends \InvalidArgumentException {
82+
}
83+
',
84+
],
85+
];
86+
}
87+
}

tests/Unit/Analyzer/ClassDescriptionBuilderTest.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,4 +280,84 @@ public function test_it_should_filter_internal_classes_with_namespaces(): void
280280
self::assertCount(1, $classDescription->getDependencies());
281281
self::assertEquals('App\MyClass', $classDescription->getDependencies()[0]->getFQCN()->toString());
282282
}
283+
284+
public function test_add_reflected_interface_should_add_to_interfaces_without_dependency(): void
285+
{
286+
$classDescription = (new ClassDescriptionBuilder())
287+
->setFilePath('src/Foo.php')
288+
->setClassName('MyClass')
289+
->addReflectedInterface('Vendor\SomeInterface')
290+
->build();
291+
292+
self::assertCount(1, $classDescription->getInterfaces());
293+
self::assertEquals('Vendor\SomeInterface', $classDescription->getInterfaces()[0]->toString());
294+
self::assertCount(0, $classDescription->getDependencies());
295+
}
296+
297+
public function test_add_reflected_interface_should_deduplicate(): void
298+
{
299+
$classDescription = (new ClassDescriptionBuilder())
300+
->setFilePath('src/Foo.php')
301+
->setClassName('MyClass')
302+
->addInterface('Vendor\SomeInterface', 10)
303+
->addReflectedInterface('Vendor\SomeInterface')
304+
->build();
305+
306+
self::assertCount(1, $classDescription->getInterfaces());
307+
self::assertEquals('Vendor\SomeInterface', $classDescription->getInterfaces()[0]->toString());
308+
}
309+
310+
public function test_add_reflected_interface_should_add_new_interfaces(): void
311+
{
312+
$classDescription = (new ClassDescriptionBuilder())
313+
->setFilePath('src/Foo.php')
314+
->setClassName('MyClass')
315+
->addInterface('Vendor\ChildInterface', 10)
316+
->addReflectedInterface('Vendor\ParentInterface')
317+
->build();
318+
319+
self::assertCount(2, $classDescription->getInterfaces());
320+
self::assertEquals('Vendor\ChildInterface', $classDescription->getInterfaces()[0]->toString());
321+
self::assertEquals('Vendor\ParentInterface', $classDescription->getInterfaces()[1]->toString());
322+
}
323+
324+
public function test_add_reflected_extends_should_add_to_extends_without_dependency(): void
325+
{
326+
$classDescription = (new ClassDescriptionBuilder())
327+
->setFilePath('src/Foo.php')
328+
->setClassName('MyClass')
329+
->addReflectedExtends('Vendor\GrandParentClass')
330+
->build();
331+
332+
self::assertCount(1, $classDescription->getExtends());
333+
self::assertEquals('Vendor\GrandParentClass', $classDescription->getExtends()[0]->toString());
334+
self::assertCount(0, $classDescription->getDependencies());
335+
}
336+
337+
public function test_add_reflected_extends_should_deduplicate(): void
338+
{
339+
$classDescription = (new ClassDescriptionBuilder())
340+
->setFilePath('src/Foo.php')
341+
->setClassName('MyClass')
342+
->addExtends('Vendor\ParentClass', 10)
343+
->addReflectedExtends('Vendor\ParentClass')
344+
->build();
345+
346+
self::assertCount(1, $classDescription->getExtends());
347+
self::assertEquals('Vendor\ParentClass', $classDescription->getExtends()[0]->toString());
348+
}
349+
350+
public function test_add_reflected_extends_should_add_new_ancestors(): void
351+
{
352+
$classDescription = (new ClassDescriptionBuilder())
353+
->setFilePath('src/Foo.php')
354+
->setClassName('MyClass')
355+
->addExtends('Vendor\ParentClass', 10)
356+
->addReflectedExtends('Vendor\GrandParentClass')
357+
->build();
358+
359+
self::assertCount(2, $classDescription->getExtends());
360+
self::assertEquals('Vendor\ParentClass', $classDescription->getExtends()[0]->toString());
361+
self::assertEquals('Vendor\GrandParentClass', $classDescription->getExtends()[1]->toString());
362+
}
283363
}

0 commit comments

Comments
 (0)