Skip to content

Commit cff3ef4

Browse files
floriankraemerFlorian Krämer
andauthored
Add allowedDependencies feature to override forbidden dependencies (#27)
- Introduced new classes `AllowedOverride` and `StillForbidden` to demonstrate the functionality of overriding forbidden dependencies with a whitelist. - Updated `ForbiddenDependenciesRule` to include an `allowedDependencies` parameter, allowing specific dependencies to bypass the forbidden rules. - Enhanced documentation for the `Dependency-Constraints-Rule` to explain the new `allowedDependencies` feature. - Added tests to validate the behavior of allowed dependencies and ensure that forbidden dependencies still trigger errors when not in the allowed list. Co-authored-by: Florian Krämer <f.kraemer@clipmyhorse.tv>
1 parent 8d2bcb1 commit cff3ef4

5 files changed

Lines changed: 251 additions & 4 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Capability\Billing\Domain;
6+
7+
// These are forbidden by the "forbid everything" pattern but allowed by the whitelist
8+
use App\Shared\ValueObject\Money;
9+
use App\Capability\UserManagement\UserManagementFacade;
10+
use Psr\Log\LoggerInterface;
11+
12+
/**
13+
* Test class that uses dependencies which are forbidden but overridden by allowedDependencies.
14+
* None of these should trigger errors because they match the allowed patterns.
15+
*/
16+
class AllowedOverride
17+
{
18+
private Money $amount;
19+
20+
private LoggerInterface $logger;
21+
22+
public function __construct(Money $amount, LoggerInterface $logger)
23+
{
24+
$this->amount = $amount;
25+
$this->logger = $logger;
26+
}
27+
28+
public function getFacade(): UserManagementFacade
29+
{
30+
return new \App\Capability\UserManagement\UserManagementFacade();
31+
}
32+
33+
public function logSomething(): void
34+
{
35+
$this->logger->info('test');
36+
}
37+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Capability\Billing\Domain;
6+
7+
// These are forbidden and NOT in the allowed list - should trigger errors
8+
use Doctrine\ORM\EntityManager;
9+
use Symfony\Component\HttpFoundation\Request;
10+
11+
/**
12+
* Test class that uses dependencies which are forbidden and NOT overridden.
13+
* These should trigger errors.
14+
*/
15+
class StillForbidden
16+
{
17+
private EntityManager $entityManager;
18+
19+
public function __construct(EntityManager $entityManager)
20+
{
21+
$this->entityManager = $entityManager;
22+
}
23+
24+
public function handleRequest(Request $request): void
25+
{
26+
// Using forbidden dependency
27+
}
28+
29+
public function createEntityManager(): EntityManager
30+
{
31+
return new \Doctrine\ORM\EntityManager();
32+
}
33+
}

docs/rules/Dependency-Constraints-Rule.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ In the example below nothing from `App\Domain` can depend on anything from `App\
8282
- `forbiddenDependencies`: Array where keys are namespace patterns that should not depend on the namespace patterns in their value arrays.
8383
- `checkFqcn` (optional, default: `false`): Enable checking of fully qualified class names in addition to use statements.
8484
- `fqcnReferenceTypes` (optional, default: all types): Array of reference types to check when `checkFqcn` is enabled.
85+
- `allowedDependencies` (optional, default: `[]`): Whitelist that overrides forbidden dependencies. If a dependency matches both a forbidden pattern and an allowed pattern, it will be allowed.
8586

8687
## FQCN Reference Types
8788

@@ -147,6 +148,51 @@ If you only want to check specific reference types (e.g., to improve performance
147148
- phpstan.rules.rule
148149
```
149150

151+
### Whitelist with allowedDependencies
152+
153+
The `allowedDependencies` parameter lets you create a "forbid everything except X" pattern without complex regex. Dependencies matching both forbidden and allowed patterns will be allowed.
154+
155+
This example forbids all third-party/namespaced dependencies in the domain layer, except for `App\Shared`, `App\Capability`, and `Psr\*`:
156+
157+
```neon
158+
-
159+
class: Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule
160+
arguments:
161+
forbiddenDependencies: [
162+
'/^App\\Capability\\.*\\Domain$/': [
163+
'/.*\\\\.*/' # Match anything with a backslash (namespaced)
164+
]
165+
]
166+
checkFqcn: true
167+
allowedDependencies: [
168+
'/^App\\Capability\\.*\\Domain$/': [
169+
'/^App\\Shared\\/',
170+
'/^App\\Capability\\/',
171+
'/^Psr\\/'
172+
]
173+
]
174+
tags:
175+
- phpstan.rules.rule
176+
```
177+
178+
This will:
179+
180+
- **Allow**: `App\Shared\ValueObject\Money`, `App\Capability\Billing\Invoice`, `Psr\Log\LoggerInterface`
181+
- **Forbid**: `Doctrine\ORM\EntityManager`, `Symfony\Component\HttpFoundation\Request`
182+
183+
Note: Root namespace classes (like `DateTime`, `Exception`) are not matched by the `/.*\\\\.*/'` pattern since they don't contain a backslash, so they are implicitly allowed.
184+
185+
## Diagram
186+
187+
```mermaid
188+
flowchart TD
189+
A[Check Dependency] --> B{Matches forbidden pattern?}
190+
B -->|No| C[Allow]
191+
B -->|Yes| D{Matches allowed pattern?}
192+
D -->|Yes| E[Allow - Override]
193+
D -->|No| F[Report Error]
194+
```
195+
150196
## Backward Compatibility
151197

152-
By default, `checkFqcn` is `false`, so existing configurations will continue to work exactly as before, checking only `use` statements. The new FQCN checking feature must be explicitly enabled.
198+
By default, `checkFqcn` is `false` and `allowedDependencies` is empty, so existing configurations will continue to work exactly as before, checking only `use` statements. The new FQCN checking and allowedDependencies features must be explicitly enabled.

src/Architecture/ForbiddenDependenciesRule.php

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,19 +66,29 @@ class ForbiddenDependenciesRule implements Rule
6666
*/
6767
private array $fqcnReferenceTypes;
6868

69+
/**
70+
* @var array<string, array<string>>
71+
* An array where the key is a regex for the source namespace and the value is
72+
* an array of regexes for allowed dependency namespaces that override forbidden ones.
73+
*/
74+
private array $allowedDependencies;
75+
6976
/**
7077
* @param array<string, array<string>> $forbiddenDependencies
7178
* @param bool $checkFqcn Enable checking of fully qualified class names (default: false for backward compatibility)
7279
* @param array<string> $fqcnReferenceTypes Which reference types to check when checkFqcn is enabled (default: all)
80+
* @param array<string, array<string>> $allowedDependencies Whitelist that overrides forbidden dependencies
7381
*/
7482
public function __construct(
7583
array $forbiddenDependencies,
7684
bool $checkFqcn = false,
77-
array $fqcnReferenceTypes = self::ALL_REFERENCE_TYPES
85+
array $fqcnReferenceTypes = self::ALL_REFERENCE_TYPES,
86+
array $allowedDependencies = []
7887
) {
7988
$this->forbiddenDependencies = $forbiddenDependencies;
8089
$this->checkFqcn = $checkFqcn;
8190
$this->fqcnReferenceTypes = $fqcnReferenceTypes;
91+
$this->allowedDependencies = $allowedDependencies;
8292
}
8393

8494
public function getNodeType(): string
@@ -131,7 +141,7 @@ public function validateUseStatements(Use_ $node, array $disallowedDependencyPat
131141
foreach ($node->uses as $use) {
132142
$usedClassName = $use->name->toString();
133143
foreach ($disallowedDependencyPatterns as $disallowedPattern) {
134-
if (preg_match($disallowedPattern, $usedClassName)) {
144+
if (preg_match($disallowedPattern, $usedClassName) && !$this->isAllowed($usedClassName, $currentNamespace)) {
135145
$errors[] = RuleErrorBuilder::message(sprintf(
136146
static::ERROR_MESSAGE,
137147
$currentNamespace,
@@ -147,6 +157,30 @@ public function validateUseStatements(Use_ $node, array $disallowedDependencyPat
147157
return $errors;
148158
}
149159

160+
/**
161+
* Check if a class name is in the allowed list for the current namespace
162+
*
163+
* @param string $className
164+
* @param string $currentNamespace
165+
* @return bool
166+
*/
167+
private function isAllowed(string $className, string $currentNamespace): bool
168+
{
169+
foreach ($this->allowedDependencies as $sourcePattern => $allowedPatterns) {
170+
if (!preg_match($sourcePattern, $currentNamespace)) {
171+
continue;
172+
}
173+
174+
foreach ($allowedPatterns as $allowedPattern) {
175+
if (preg_match($allowedPattern, $className)) {
176+
return true;
177+
}
178+
}
179+
}
180+
181+
return false;
182+
}
183+
150184
/**
151185
* Process FQCN references in various node types
152186
*
@@ -406,7 +440,7 @@ private function validateClassReference(string $className, string $currentNamesp
406440
}
407441

408442
foreach ($disallowedDependencyPatterns as $disallowedPattern) {
409-
if (preg_match($disallowedPattern, $className)) {
443+
if (preg_match($disallowedPattern, $className) && !$this->isAllowed($className, $currentNamespace)) {
410444
$errors[] = RuleErrorBuilder::message(sprintf(
411445
static::ERROR_MESSAGE,
412446
$currentNamespace,
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Phauthentic\PHPStanRules\Tests\TestCases\Architecture;
6+
7+
use Phauthentic\PHPStanRules\Architecture\ForbiddenDependenciesRule;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Testing\RuleTestCase;
10+
11+
/**
12+
* Test allowedDependencies feature that overrides forbidden dependencies
13+
*
14+
* @extends RuleTestCase<ForbiddenDependenciesRule>
15+
*/
16+
class ForbiddenDependenciesAllowedTest extends RuleTestCase
17+
{
18+
protected function getRule(): Rule
19+
{
20+
return new ForbiddenDependenciesRule(
21+
// Forbid everything (any namespaced class)
22+
[
23+
'/^App\\\\Capability\\\\.*\\\\Domain$/' => [
24+
'/.*\\\\.*/' // Match anything with a backslash (namespaced)
25+
]
26+
],
27+
true, // Enable FQCN checking
28+
[ // All reference types (default)
29+
'new',
30+
'param',
31+
'return',
32+
'property',
33+
'static_call',
34+
'static_property',
35+
'class_const',
36+
'instanceof',
37+
'catch',
38+
'extends',
39+
'implements',
40+
],
41+
// Allow specific namespaces as override
42+
[
43+
'/^App\\\\Capability\\\\.*\\\\Domain$/' => [
44+
'/^App\\\\Shared\\\\/',
45+
'/^App\\\\Capability\\\\/',
46+
'/^Psr\\\\/'
47+
]
48+
]
49+
);
50+
}
51+
52+
/**
53+
* Test that allowed dependencies do not trigger errors
54+
*/
55+
public function testAllowedDependenciesOverrideForbidden(): void
56+
{
57+
// Should have no errors - all dependencies match allowed patterns
58+
$this->analyse([__DIR__ . '/../../../data/ForbiddenDependenciesAllowed/AllowedOverride.php'], []);
59+
}
60+
61+
/**
62+
* Test that forbidden dependencies not in allowed list still trigger errors
63+
*/
64+
public function testForbiddenDependenciesStillReportErrors(): void
65+
{
66+
$this->analyse([__DIR__ . '/../../../data/ForbiddenDependenciesAllowed/StillForbidden.php'], [
67+
[
68+
'Forbidden dependency: A class in namespace `App\Capability\Billing\Domain` is not allowed to depend on `Doctrine\ORM\EntityManager`.',
69+
8,
70+
],
71+
[
72+
'Forbidden dependency: A class in namespace `App\Capability\Billing\Domain` is not allowed to depend on `Symfony\Component\HttpFoundation\Request`.',
73+
9,
74+
],
75+
[
76+
'Forbidden dependency: A class in namespace `App\Capability\Billing\Domain` is not allowed to depend on `Doctrine\ORM\EntityManager`.',
77+
17,
78+
],
79+
[
80+
'Forbidden dependency: A class in namespace `App\Capability\Billing\Domain` is not allowed to depend on `Doctrine\ORM\EntityManager`.',
81+
19,
82+
],
83+
[
84+
'Forbidden dependency: A class in namespace `App\Capability\Billing\Domain` is not allowed to depend on `Symfony\Component\HttpFoundation\Request`.',
85+
24,
86+
],
87+
[
88+
'Forbidden dependency: A class in namespace `App\Capability\Billing\Domain` is not allowed to depend on `Doctrine\ORM\EntityManager`.',
89+
29,
90+
],
91+
[
92+
'Forbidden dependency: A class in namespace `App\Capability\Billing\Domain` is not allowed to depend on `Doctrine\ORM\EntityManager`.',
93+
31,
94+
],
95+
]);
96+
}
97+
}

0 commit comments

Comments
 (0)