Skip to content
Draft
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,10 +368,12 @@ You can add multiple parameters, the violation will happen when one of them matc
```php
$rules[] = Rule::allClasses()
->that(new ResideInOneOfTheseNamespaces('App\Domain'))
->should(new NotHaveDependencyOutsideNamespace('App\Domain', ['Ramsey\Uuid'], true))
->should(new NotHaveDependencyOutsideNamespace('App\Domain', ['Ramsey\Uuid']))
->because('we want protect our domain except for Ramsey\Uuid');
```

Note: PHP core classes (e.g., `DateTime`, `Exception`, `PDO`) are automatically excluded from dependency checks.

### Not have a name matching a pattern

```php
Expand Down
27 changes: 27 additions & 0 deletions src/Analyzer/ClassDescriptionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,11 @@ public function addInterface(string $FQCN, int $line): self

public function addDependency(ClassDependency $cd): self
{
// Filter out PHP core classes
if ($this->isPhpCoreClass($cd)) {
return $this;
}

$this->classDependencies[] = $cd;

return $this;
Expand Down Expand Up @@ -169,4 +174,26 @@ public function build(): ClassDescription
$this->filePath
);
}

/**
* Checks if a dependency is a PHP core/internal class.
* Uses Reflection to detect PHP built-in classes from core and extensions
* (e.g., DateTime, Exception, MongoDB\Driver\Manager, Swoole\Server).
*/
private function isPhpCoreClass(ClassDependency $dependency): bool
{
$fqcn = $dependency->getFQCN();

try {
/** @var class-string $className */
$className = $fqcn->toString();
$reflection = new \ReflectionClass($className);

return $reflection->isInternal();
} catch (\ReflectionException $e) {
// Class doesn't exist in the current environment
// It's likely a user-defined class in the project being analyzed
return false;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,10 @@ class NotHaveDependencyOutsideNamespace implements Expression
/** @var array<string> */
private array $externalDependenciesToExclude;

private bool $excludeCoreNamespace;

public function __construct(string $namespace, array $externalDependenciesToExclude = [], bool $excludeCoreNamespace = false)
public function __construct(string $namespace, array $externalDependenciesToExclude = [])
{
$this->namespace = $namespace;
$this->externalDependenciesToExclude = $externalDependenciesToExclude;
$this->excludeCoreNamespace = $excludeCoreNamespace;
}

public function describe(ClassDescription $theClass, string $because): Description
Expand All @@ -49,10 +46,6 @@ public function evaluate(ClassDescription $theClass, Violations $violations, str
continue;
}

if ($this->excludeCoreNamespace && '' === $externalDep->getFQCN()->namespace()) {
continue;
}

$violation = Violation::createWithErrorLine(
$theClass->getFQCN(),
ViolationMessage::withDescription(
Expand Down
97 changes: 97 additions & 0 deletions tests/Unit/Analyzer/ClassDescriptionBuilderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,4 +180,101 @@ public function test_it_should_create_not_trait(): void
self::assertInstanceOf(ClassDescription::class, $classDescription);
self::assertFalse($classDescription->isTrait());
}

public function test_it_should_filter_out_php_core_classes(): void
{
$FQCN = 'MyClass';

$classDescription = (new ClassDescriptionBuilder())
->setFilePath('src/Foo.php')
->setClassName($FQCN)
->addDependency(new ClassDependency('DateTime', 10))
->addDependency(new ClassDependency('Exception', 15))
->addDependency(new ClassDependency('PDO', 20))
->build();

self::assertInstanceOf(ClassDescription::class, $classDescription);

// PHP core classes should be filtered out
self::assertCount(0, $classDescription->getDependencies());
}

public function test_it_should_not_filter_user_defined_classes_in_root_namespace(): void
{
$FQCN = 'MyClass';

$classDescription = (new ClassDescriptionBuilder())
->setFilePath('src/Foo.php')
->setClassName($FQCN)
->addDependency(new ClassDependency('NonExistentUserClass', 10))
->build();

self::assertInstanceOf(ClassDescription::class, $classDescription);

// User-defined classes in root namespace should NOT be filtered
self::assertCount(1, $classDescription->getDependencies());
self::assertEquals('NonExistentUserClass', $classDescription->getDependencies()[0]->getFQCN()->toString());
}

public function test_it_should_not_filter_user_defined_classes_with_namespace(): void
{
$FQCN = 'MyClass';

$classDescription = (new ClassDescriptionBuilder())
->setFilePath('src/Foo.php')
->setClassName($FQCN)
->addDependency(new ClassDependency('Vendor\Package\SomeClass', 10))
->addDependency(new ClassDependency('App\Domain\Entity', 15))
->build();

self::assertInstanceOf(ClassDescription::class, $classDescription);

// User-defined classes with namespaces should not be filtered
self::assertCount(2, $classDescription->getDependencies());
}

public function test_it_should_filter_mixed_dependencies_correctly(): void
{
$FQCN = 'MyClass';

$classDescription = (new ClassDescriptionBuilder())
->setFilePath('src/Foo.php')
->setClassName($FQCN)
->addDependency(new ClassDependency('DateTime', 10)) // PHP core - filtered
->addDependency(new ClassDependency('Vendor\Package\SomeClass', 15)) // Namespaced - kept
->addDependency(new ClassDependency('Exception', 20)) // PHP core - filtered
->addDependency(new ClassDependency('NonExistentUserClass', 25)) // User root class - kept
->addDependency(new ClassDependency('PDO', 30)) // PHP core - filtered
->build();

self::assertInstanceOf(ClassDescription::class, $classDescription);

// Should keep only the 2 non-PHP-core dependencies
self::assertCount(2, $classDescription->getDependencies());

$dependencies = $classDescription->getDependencies();
self::assertEquals('Vendor\Package\SomeClass', $dependencies[0]->getFQCN()->toString());
self::assertEquals('NonExistentUserClass', $dependencies[1]->getFQCN()->toString());
}

public function test_it_should_filter_internal_classes_with_namespaces(): void
{
$FQCN = 'MyClass';

// ReflectionClass is a PHP internal class in the root namespace
// If other internal namespaced classes exist (e.g., MongoDB\Driver\Manager),
// they should also be filtered. We test with ReflectionClass which is always available.
$classDescription = (new ClassDescriptionBuilder())
->setFilePath('src/Foo.php')
->setClassName($FQCN)
->addDependency(new ClassDependency('ReflectionClass', 10)) // Internal root - filtered
->addDependency(new ClassDependency('App\MyClass', 15)) // User namespaced - kept
->build();

self::assertInstanceOf(ClassDescription::class, $classDescription);

// ReflectionClass should be filtered, only App\MyClass should remain
self::assertCount(1, $classDescription->getDependencies());
self::assertEquals('App\MyClass', $classDescription->getDependencies()[0]->getFQCN()->toString());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,13 @@ public function test_it_should_return_false_if_depends_on_namespace(): void
->setClassName('HappyIsland')
->addDependency(new ClassDependency('myNamespace', 100))
->addDependency(new ClassDependency('another\class', 200))
->addDependency(new ClassDependency('\DateTime', 300))
->build();

$because = 'we want to add this rule for our software';
$violations = new Violations();
$notHaveDependencyOutsideNamespace->evaluate($classDescription, $violations, $because);

self::assertEquals(2, $violations->count());
self::assertEquals(1, $violations->count());
}

public function test_it_should_not_return_violation_error_if_dependency_excluded(): void
Expand All @@ -84,20 +83,22 @@ public function test_it_should_not_return_violation_error_if_dependency_excluded
self::assertEquals(0, $violations->count());
}

public function test_it_should_not_return_violation_error_if_core_dependency_excluded(): void
public function test_it_should_automatically_exclude_php_core_classes(): void
{
$notHaveDependencyOutsideNamespace = new NotHaveDependencyOutsideNamespace('myNamespace', [], true);
$notHaveDependencyOutsideNamespace = new NotHaveDependencyOutsideNamespace('myNamespace');

$classDescription = (new ClassDescriptionBuilder())
->setFilePath('src/Foo.php')
->setClassName('HappyIsland')
->addDependency(new ClassDependency('\DateTime', 100))
->addDependency(new ClassDependency('another\class', 100))
->build();

$because = 'we want to add this rule for our software';
$violations = new Violations();
$notHaveDependencyOutsideNamespace->evaluate($classDescription, $violations, $because);

self::assertEquals(0, $violations->count());
// PHP core classes are automatically filtered at the ClassDescriptionBuilder level
// So only 'another\class' should be reported as a violation
self::assertEquals(1, $violations->count());
}
}