diff --git a/src/Blueprint.php b/src/Blueprint.php index cf4c5bb..97230dd 100644 --- a/src/Blueprint.php +++ b/src/Blueprint.php @@ -231,9 +231,9 @@ private function getUsagePathAndLines(Layer $layer, string $objectName, string $ $class = PhpCoreExpressions::getClass($target) ?? Name::class; - $nodes = ServiceContainer::$nodeFinder->findInstanceOf( + $nodes = ServiceContainer::$nodeFinder->findInstanceOf( //@phpstan-ignore-line $dependOnObject->stmts, - $class, + $class, //@phpstan-ignore-line ); /** @var array $nodes */ diff --git a/src/Contracts/ArchExpectation.php b/src/Contracts/ArchExpectation.php index 431b206..0de7a45 100644 --- a/src/Contracts/ArchExpectation.php +++ b/src/Contracts/ArchExpectation.php @@ -46,4 +46,12 @@ public function mergeExcludeCallbacks(array $callbacks): void; * @internal */ public function excludeCallbacks(): array; + + /** + * Expectations that the given "targets" or "dependencies" are to ignore. + * + * @param array|string $expectations + * @return $this + */ + public function unless(array|string $expectations): self; } diff --git a/src/GroupArchExpectation.php b/src/GroupArchExpectation.php index bf0d6d7..881b605 100644 --- a/src/GroupArchExpectation.php +++ b/src/GroupArchExpectation.php @@ -138,4 +138,16 @@ private function ensureLazyExpectationIsVerified(): void $expectation->ensureLazyExpectationIsVerified(); } } + + /** + * {@inheritDoc} + */ + public function unless(mixed $expectations): self + { + foreach ($this->expectations as $expectation) { + $expectation->unless($expectations); + } + + return $this; + } } diff --git a/src/SingleArchExpectation.php b/src/SingleArchExpectation.php index 97d2b67..493e684 100644 --- a/src/SingleArchExpectation.php +++ b/src/SingleArchExpectation.php @@ -170,4 +170,19 @@ public function ensureLazyExpectationIsVerified(): void ($this->opposite)(); // @phpstan-ignore-line } } + + /** + * {@inheritDoc} + */ + public function unless(array|string $expectations): self + { + $modifier = UnlessModifier::make($this->expectation); + $expectations = is_array($expectations) ? $expectations : [$expectations => true]; + + $this->ignoring([ + ...$modifier->targetsToIgnore($expectations, LayerOptions::fromExpectation($this)), + ]); + + return $this; + } } diff --git a/src/UnlessModifier.php b/src/UnlessModifier.php new file mode 100644 index 0000000..0f4883b --- /dev/null +++ b/src/UnlessModifier.php @@ -0,0 +1,114 @@ + + */ +final readonly class UnlessModifier +{ + public static function make(Expectation $expectation): self + { + return new self($expectation); + } + + public function __construct(protected Expectation $expectation) + { + } + + /* @phpstan-ignore-next-line */ + public function targetsToIgnore(array $expectations, LayerOptions $options): array + { + $ignoreArr = []; + $blueprint = Blueprint::make( + Targets::fromExpectation($this->expectation), + Dependencies::fromExpectationInput([]), + ); + + $targets = (fn (): array => $this->target->value)->call($blueprint); + $layerFactory = (fn (): \Pest\Arch\Factories\LayerFactory => $this->layerFactory)->call($blueprint); + + foreach ($targets as $targetValue) { + $targetLayer = $layerFactory->make($options, $targetValue); + + foreach ($targetLayer as $object) { + /** @var \Pest\Arch\Objects\ObjectDescription $objectDescription */ + $objectDescription = $object; + + foreach ($expectations as $expectation => $value) { + if ($ignore = $this->handleExpectation($objectDescription, $expectation, $value)) { // @phpstan-ignore-line + $ignoreArr[] = $ignore; + } + } + } + } + + return $ignoreArr; + } + + /** + * Handles the expectation. + */ + private function handleExpectation(ObjectDescription $objectDescription, string $expectation, mixed $value): ?string + { + return match ($expectation) { + 'abstractParent' => $this->handleAbstractParent($objectDescription, $value), + 'extends' => $this->handleExtends($objectDescription, $value), + default => null, + }; + } + + /** + * Handles the "extends" expectation. + */ + private function handleExtends(ObjectDescription $objectDescription, mixed $value): ?string + { + $reflection = $objectDescription->reflectionClass; + + if ($value === true && $reflection->getParentClass() !== false) { + return $reflection->getName(); + } + + if (! is_string($value)) { + return null; + } + + if (! $reflection->isSubclassOf($value)) { + return null; + } + + return $reflection->getName(); + } + + /** + * Handles the "abstractParent" expectation. + */ + private function handleAbstractParent(ObjectDescription $objectDescription, mixed $value): ?string + { + $reflection = $objectDescription->reflectionClass; + + if ($reflection->getParentClass() === false) { + return null; + } + + if ($value !== true) { + return null; + } + + if (! $reflection->getParentClass()->isAbstract()) { + return null; + } + + return $reflection->getName(); + } +} diff --git a/tests/Arch.php b/tests/Arch.php index 0e2aaae..6f6bca1 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -77,3 +77,18 @@ ->expect(Targets::class) ->toOnlyUse([Expectation::class]) ->ignoring('PHPUnit\Framework'); + +arch('avoid mutation unless extending controller') + ->expect('Tests\Fixtures\Controllers\Service') + ->toHavePrefix('Service') + ->toBeReadonly() + ->unless([ + 'extends' => Tests\Fixtures\Controller::class, + 'abstractParent' => true, + ]); + +arch('avoid mutation if extends') + ->expect('Tests\Fixtures\Controllers\Service') + ->toHavePrefix('Service') + ->toBeReadonly() + ->unless('extends'); diff --git a/tests/Fixtures/Controllers/AbstractServiceController.php b/tests/Fixtures/Controllers/AbstractServiceController.php new file mode 100644 index 0000000..21d02d5 --- /dev/null +++ b/tests/Fixtures/Controllers/AbstractServiceController.php @@ -0,0 +1,9 @@ +