diff --git a/README.md b/README.md index c0bd079..a8ed72d 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,49 @@ includes: | `phpnomad.general.singletonInBusiness` | `::instance()` singleton calls must not be used outside of Facade classes. Use dependency injection. | | `phpnomad.general.globalKeyword` | The `global` keyword must not be used. Use dependency injection. | +### Stack Elevator (layer purity) + +Enforces the Stack Elevator doctrine (Novatorius coding standards KB: +`initiatives-nomadic-development-coding-standards-the-stack-elevator`): code is layered Core (pure business +concepts) → Service (implementation logic) → Platform (system integration), and dependencies only +point upward (Platform → Service → Core). Layers are marked by namespace segments; because a layer +name can also appear as a domain name (e.g. `Novatorius\Quartermaster\Platform\Core\...`), the +**last** matching segment in a namespace determines its layer. + +| Identifier | Description | +|---|---| +| `phpnomad.stackElevator.coreDependsOnService` | Core-layer code must not import or reference Service-layer symbols. | +| `phpnomad.stackElevator.coreDependsOnPlatform` | Core-layer code must not import or reference Platform-layer symbols. | +| `phpnomad.stackElevator.coreDenylist` | Core-layer code must not reference denylisted third-party namespaces. | +| `phpnomad.stackElevator.serviceDependsOnPlatform` | Service-layer code must not import or reference Platform-layer symbols. | +| `phpnomad.stackElevator.serviceDenylist` | Service-layer code must not reference denylisted third-party namespaces. | + +Checked references: `use` statements, extends/implements, trait uses, property/parameter/return +types, instantiations, static calls and constant fetches, `instanceof`, `catch` types, attributes, +and fully qualified function calls. Doc-block-only references are not checked. + +All settings are configurable via `phpstan.neon` (defaults shown): + +```neon +parameters: + phpNomadStackElevator: + checkCoreLayer: true + checkServiceLayer: true + namespaceRoots: # vendor roots the convention applies to + - Novatorius + - PHPNomad + coreSegments: [Core] # namespace segments marking each layer + serviceSegments: [Service] + platformSegments: [Platform, WordPress, Integration] + excludeNamespaces: [] # grandfathered namespace prefixes ("*" wildcards allowed) + coreDenylist: [] # third-party prefixes Core may never reference + serviceDenylist: [] # third-party prefixes Service may never reference +``` + +Existing repos will have violations when first enabling these rules. Grandfather them with +`excludeNamespaces` (e.g. a composition root such as `Novatorius\Quartermaster\Service`, whose job +is to wire all layers together) or a PHPStan baseline, then shrink the list over time. + ## Suppressing Rules Use PHPStan's built-in ignore syntax: diff --git a/composer.json b/composer.json index 669c538..0b92ad3 100644 --- a/composer.json +++ b/composer.json @@ -27,7 +27,8 @@ "PHPNomad\\PhpstanRules\\Tests\\": "tests/" }, "classmap": [ - "tests/stubs/" + "tests/stubs/", + "tests/Rules/StackElevator/data/" ] }, "extra": { diff --git a/extension.neon b/extension.neon index fdd9df7..6b6aa18 100644 --- a/extension.neon +++ b/extension.neon @@ -1,6 +1,62 @@ +parametersSchema: + phpNomadStackElevator: structure([ + checkCoreLayer: bool() + checkServiceLayer: bool() + namespaceRoots: listOf(string()) + coreSegments: listOf(string()) + serviceSegments: listOf(string()) + platformSegments: listOf(string()) + excludeNamespaces: listOf(string()) + coreDenylist: listOf(string()) + serviceDenylist: listOf(string()) + ]) + +parameters: + phpNomadStackElevator: + checkCoreLayer: true + checkServiceLayer: true + namespaceRoots: + - Novatorius + - PHPNomad + coreSegments: + - Core + serviceSegments: + - Service + platformSegments: + - Platform + - WordPress + - Integration + excludeNamespaces: [] + coreDenylist: [] + serviceDenylist: [] + services: - class: PHPNomad\PhpstanRules\Services\ClassTypeResolver + - + class: PHPNomad\PhpstanRules\Services\StackElevatorNameExtractor + - + class: PHPNomad\PhpstanRules\Services\StackElevatorLayerResolver + arguments: + namespaceRoots: %phpNomadStackElevator.namespaceRoots% + coreSegments: %phpNomadStackElevator.coreSegments% + serviceSegments: %phpNomadStackElevator.serviceSegments% + platformSegments: %phpNomadStackElevator.platformSegments% + excludeNamespaces: %phpNomadStackElevator.excludeNamespaces% + - + class: PHPNomad\PhpstanRules\Rules\StackElevator\CoreLayerPurityRule + arguments: + enabled: %phpNomadStackElevator.checkCoreLayer% + denylist: %phpNomadStackElevator.coreDenylist% + tags: + - phpstan.rules.rule + - + class: PHPNomad\PhpstanRules\Rules\StackElevator\ServiceLayerPurityRule + arguments: + enabled: %phpNomadStackElevator.checkServiceLayer% + denylist: %phpNomadStackElevator.serviceDenylist% + tags: + - phpstan.rules.rule rules: - PHPNomad\PhpstanRules\Rules\Models\ModelsMustBeFinalRule diff --git a/lib/Enums/StackLayer.php b/lib/Enums/StackLayer.php new file mode 100644 index 0000000..bd9688c --- /dev/null +++ b/lib/Enums/StackLayer.php @@ -0,0 +1,15 @@ + + */ +class CoreLayerPurityRule implements Rule +{ + /** + * @param list $denylist Namespace prefixes outside the configured roots that Core code must not reference. + */ + public function __construct( + private StackElevatorLayerResolver $resolver, + private StackElevatorNameExtractor $extractor = new StackElevatorNameExtractor(), + private bool $enabled = true, + private array $denylist = [] + ) { + } + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->enabled) { + return []; + } + + $names = $this->extractor->extract($node); + + if ($names === []) { + return []; + } + + $namespace = $scope->getNamespace(); + + if ($namespace === null + || !$this->resolver->isUnderRoots($namespace) + || $this->resolver->layerOfNamespace($namespace) !== StackLayer::Core + || $this->resolver->isExcluded($namespace) + ) { + return []; + } + + $errors = []; + + foreach ($names as $name) { + $target = $scope->resolveName($name); + + if ($this->resolver->isUnderRoots($target)) { + $targetLayer = $this->resolver->layerOfSymbol($target); + + if ($targetLayer === StackLayer::Service) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Core layer must not depend on the Service layer: %s is Service-layer code. Stack Elevator: dependencies only point upward (Platform → Service → Core). Move the abstraction into Core or invert the dependency.', + $target + )) + ->identifier('phpnomad.stackElevator.coreDependsOnService') + ->line($name->getStartLine()) + ->build(); + } elseif ($targetLayer === StackLayer::Platform) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Core layer must not depend on the Platform layer: %s is Platform-layer code. Stack Elevator: dependencies only point upward (Platform → Service → Core). Define a Core contract and implement it in the Platform layer.', + $target + )) + ->identifier('phpnomad.stackElevator.coreDependsOnPlatform') + ->line($name->getStartLine()) + ->build(); + } + + continue; + } + + if ($this->resolver->matchesAnyPrefix($target, $this->denylist)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Core layer must stay framework-agnostic: %s is denylisted for Core code by the Stack Elevator configuration (phpNomadStackElevator.coreDenylist). Wrap it behind a Core contract implemented in a lower layer.', + $target + )) + ->identifier('phpnomad.stackElevator.coreDenylist') + ->line($name->getStartLine()) + ->build(); + } + } + + return $errors; + } +} diff --git a/lib/Rules/StackElevator/ServiceLayerPurityRule.php b/lib/Rules/StackElevator/ServiceLayerPurityRule.php new file mode 100644 index 0000000..ec722a9 --- /dev/null +++ b/lib/Rules/StackElevator/ServiceLayerPurityRule.php @@ -0,0 +1,93 @@ + + */ +class ServiceLayerPurityRule implements Rule +{ + /** + * @param list $denylist Namespace prefixes outside the configured roots that Service code must not reference. + */ + public function __construct( + private StackElevatorLayerResolver $resolver, + private StackElevatorNameExtractor $extractor = new StackElevatorNameExtractor(), + private bool $enabled = true, + private array $denylist = [] + ) { + } + + public function getNodeType(): string + { + return Node::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$this->enabled) { + return []; + } + + $names = $this->extractor->extract($node); + + if ($names === []) { + return []; + } + + $namespace = $scope->getNamespace(); + + if ($namespace === null + || !$this->resolver->isUnderRoots($namespace) + || $this->resolver->layerOfNamespace($namespace) !== StackLayer::Service + || $this->resolver->isExcluded($namespace) + ) { + return []; + } + + $errors = []; + + foreach ($names as $name) { + $target = $scope->resolveName($name); + + if ($this->resolver->isUnderRoots($target)) { + if ($this->resolver->layerOfSymbol($target) === StackLayer::Platform) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Service layer must not depend on the Platform layer: %s is Platform-layer code. Stack Elevator: dependencies only point upward (Platform → Service → Core). Define a Core contract and let the Platform layer implement it.', + $target + )) + ->identifier('phpnomad.stackElevator.serviceDependsOnPlatform') + ->line($name->getStartLine()) + ->build(); + } + + continue; + } + + if ($this->resolver->matchesAnyPrefix($target, $this->denylist)) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Service layer must stay platform-agnostic: %s is denylisted for Service code by the Stack Elevator configuration (phpNomadStackElevator.serviceDenylist). Wrap it behind a Core contract implemented in the Platform layer.', + $target + )) + ->identifier('phpnomad.stackElevator.serviceDenylist') + ->line($name->getStartLine()) + ->build(); + } + } + + return $errors; + } +} diff --git a/lib/Services/StackElevatorLayerResolver.php b/lib/Services/StackElevatorLayerResolver.php new file mode 100644 index 0000000..61c5801 --- /dev/null +++ b/lib/Services/StackElevatorLayerResolver.php @@ -0,0 +1,117 @@ + $namespaceRoots Vendor roots the convention applies to (e.g. ["Novatorius", "PHPNomad"]). + * @param list $coreSegments Namespace segments that mark the Core layer. + * @param list $serviceSegments Namespace segments that mark the Service layer. + * @param list $platformSegments Namespace segments that mark the Platform layer. + * @param list $excludeNamespaces Namespace prefixes to grandfather (skip entirely). Supports "*" wildcards. + */ + public function __construct( + private array $namespaceRoots, + private array $coreSegments, + private array $serviceSegments, + private array $platformSegments, + private array $excludeNamespaces = [] + ) { + } + + public function isUnderRoots(string $name): bool + { + return $this->matchesAnyPrefix($name, $this->namespaceRoots); + } + + public function isExcluded(string $namespace): bool + { + return $this->matchesAnyPrefix($namespace, $this->excludeNamespaces); + } + + /** + * Determines the layer of a namespace. Every segment participates. + */ + public function layerOfNamespace(string $namespace): ?StackLayer + { + return $this->layerOfSegments(explode('\\', trim($namespace, '\\'))); + } + + /** + * Determines the layer of a fully qualified symbol (class, function, + * or constant). The final segment is the symbol's own name, so it is + * ignored — only the namespace it lives in marks the layer. + */ + public function layerOfSymbol(string $symbol): ?StackLayer + { + $parts = explode('\\', trim($symbol, '\\')); + array_pop($parts); + + return $this->layerOfSegments($parts); + } + + /** + * Checks a name against a list of namespace prefixes. Entries match + * the namespace itself or anything nested beneath it, and may use + * "*" wildcards (fnmatch semantics). + * + * @param list $prefixes + */ + public function matchesAnyPrefix(string $name, array $prefixes): bool + { + $name = trim($name, '\\'); + + foreach ($prefixes as $prefix) { + $prefix = trim($prefix, '\\'); + + if ($prefix === '') { + continue; + } + + if (str_contains($prefix, '*')) { + if (fnmatch($prefix, $name, FNM_NOESCAPE) || fnmatch($prefix . '\\*', $name, FNM_NOESCAPE)) { + return true; + } + + continue; + } + + if ($name === $prefix || str_starts_with($name, $prefix . '\\')) { + return true; + } + } + + return false; + } + + /** + * @param list $segments + */ + private function layerOfSegments(array $segments): ?StackLayer + { + $layer = null; + + foreach ($segments as $segment) { + if (in_array($segment, $this->coreSegments, true)) { + $layer = StackLayer::Core; + } elseif (in_array($segment, $this->serviceSegments, true)) { + $layer = StackLayer::Service; + } elseif (in_array($segment, $this->platformSegments, true)) { + $layer = StackLayer::Platform; + } + } + + return $layer; + } +} diff --git a/lib/Services/StackElevatorNameExtractor.php b/lib/Services/StackElevatorNameExtractor.php new file mode 100644 index 0000000..2013f03 --- /dev/null +++ b/lib/Services/StackElevatorNameExtractor.php @@ -0,0 +1,150 @@ + + */ + public function extract(Node $node): array + { + if ($node instanceof Stmt\Use_) { + return array_values(array_map(static fn ($use) => $use->name, $node->uses)); + } + + if ($node instanceof Stmt\GroupUse) { + $names = []; + + foreach ($node->uses as $use) { + $name = Name::concat($node->prefix, $use->name, $use->name->getAttributes()); + + if ($name !== null) { + $names[] = $name; + } + } + + return $names; + } + + if ($node instanceof Stmt\TraitUse) { + return array_values($node->traits); + } + + if ($node instanceof Stmt\Class_) { + $names = array_values($node->implements); + + if ($node->extends !== null) { + $names[] = $node->extends; + } + + return $names; + } + + if ($node instanceof Stmt\Interface_) { + return array_values($node->extends); + } + + if ($node instanceof Stmt\Enum_) { + return array_values($node->implements); + } + + if ($node instanceof Stmt\Property) { + return $this->fromType($node->type); + } + + if ($node instanceof Stmt\ClassConst) { + return $this->fromType($node->type); + } + + if ($node instanceof FunctionLike) { + $names = []; + + foreach ($node->getParams() as $param) { + foreach ($this->fromType($param->type) as $name) { + $names[] = $name; + } + } + + foreach ($this->fromType($node->getReturnType()) as $name) { + $names[] = $name; + } + + return $names; + } + + if ($node instanceof Expr\New_ + || $node instanceof Expr\StaticCall + || $node instanceof Expr\StaticPropertyFetch + || $node instanceof Expr\ClassConstFetch + || $node instanceof Expr\Instanceof_ + ) { + return $node->class instanceof Name ? [$node->class] : []; + } + + if ($node instanceof Stmt\Catch_) { + return array_values($node->types); + } + + if ($node instanceof Expr\FuncCall) { + return $node->name instanceof Name && $node->name->isFullyQualified() ? [$node->name] : []; + } + + if ($node instanceof Node\Attribute) { + return [$node->name]; + } + + return []; + } + + /** + * @param Identifier|Name|ComplexType|null $type + * + * @return list + */ + private function fromType(Identifier|Name|ComplexType|null $type): array + { + if ($type instanceof Name) { + return [$type]; + } + + if ($type instanceof NullableType) { + return $this->fromType($type->type); + } + + if ($type instanceof UnionType || $type instanceof IntersectionType) { + $names = []; + + foreach ($type->types as $inner) { + foreach ($this->fromType($inner) as $name) { + $names[] = $name; + } + } + + return $names; + } + + return []; + } +} diff --git a/tests/Rules/StackElevator/CoreLayerPurityRuleTest.php b/tests/Rules/StackElevator/CoreLayerPurityRuleTest.php new file mode 100644 index 0000000..6ca2f2f --- /dev/null +++ b/tests/Rules/StackElevator/CoreLayerPurityRuleTest.php @@ -0,0 +1,147 @@ + + */ +class CoreLayerPurityRuleTest extends RuleTestCase +{ + private bool $enabled = true; + + /** @var list */ + private array $excludeNamespaces = []; + + /** @var list */ + private array $denylist = []; + + protected function getRule(): Rule + { + return new CoreLayerPurityRule( + new StackElevatorLayerResolver( + namespaceRoots: ['Novatorius', 'PHPNomad'], + coreSegments: ['Core'], + serviceSegments: ['Service'], + platformSegments: ['Platform', 'WordPress', 'Integration'], + excludeNamespaces: $this->excludeNamespaces + ), + enabled: $this->enabled, + denylist: $this->denylist + ); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan.neon']; + } + + private static function serviceMessage(string $symbol): string + { + return sprintf( + 'Core layer must not depend on the Service layer: %s is Service-layer code. Stack Elevator: dependencies only point upward (Platform → Service → Core). Move the abstraction into Core or invert the dependency.', + $symbol + ); + } + + private static function platformMessage(string $symbol): string + { + return sprintf( + 'Core layer must not depend on the Platform layer: %s is Platform-layer code. Stack Elevator: dependencies only point upward (Platform → Service → Core). Define a Core contract and implement it in the Platform layer.', + $symbol + ); + } + + private static function denylistMessage(string $symbol): string + { + return sprintf( + 'Core layer must stay framework-agnostic: %s is denylisted for Core code by the Stack Elevator configuration (phpNomadStackElevator.coreDenylist). Wrap it behind a Core contract implemented in a lower layer.', + $symbol + ); + } + + public function testCleanCoreHasNoErrors(): void + { + $this->analyse([__DIR__ . '/data/CleanCore.php'], []); + } + + public function testCoreReferencingServiceIsFlagged(): void + { + $contactService = 'Novatorius\Fixture\Contacts\Service\ContactService'; + $formatter = 'Novatorius\Fixture\Contacts\Service\Helpers\Formatter'; + + $this->analyse([__DIR__ . '/data/CoreUsesService.php'], [ + [self::serviceMessage($contactService), 5], + [self::serviceMessage($contactService), 10], + [self::serviceMessage($contactService), 13], + [self::serviceMessage($formatter), 20], + [self::serviceMessage($contactService), 21], + ]); + } + + public function testCoreReferencingPlatformIsFlagged(): void + { + $adapter = 'Novatorius\Fixture\Contacts\Platform\WordPressContactAdapter'; + $optionStore = 'Novatorius\Fixture\WordPress\OptionStore'; + $stripeClient = 'Novatorius\Fixture\Integration\Stripe\Client'; + + $this->analyse([__DIR__ . '/data/CoreUsesPlatform.php'], [ + [self::platformMessage($adapter), 5], + [self::platformMessage($optionStore), 6], + [self::platformMessage($optionStore), 12], + [self::platformMessage($adapter), 13], + [self::platformMessage($stripeClient), 14], + ]); + } + + public function testServiceLayerFilesAreIgnoredByCoreRule(): void + { + $this->analyse([__DIR__ . '/data/ServiceUsesCore.php'], []); + $this->analyse([__DIR__ . '/data/ServiceUsesPlatform.php'], []); + } + + public function testExcludedNamespaceIsGrandfathered(): void + { + $this->excludeNamespaces = ['Novatorius\Fixture\Contacts\Core']; + + $this->analyse([__DIR__ . '/data/CoreUsesService.php'], []); + } + + public function testThirdPartySymbolsAreIgnored(): void + { + $this->analyse([__DIR__ . '/data/CoreUsesThirdParty.php'], []); + } + + public function testDenylistedThirdPartySymbolsAreFlagged(): void + { + $this->denylist = ['Symfony\Component\HttpFoundation']; + + $request = 'Symfony\Component\HttpFoundation\Request'; + + $this->analyse([__DIR__ . '/data/CoreUsesThirdParty.php'], [ + [self::denylistMessage($request), 5], + [self::denylistMessage($request), 10], + ]); + } + + public function testLastLayerSegmentDeterminesTheLayer(): void + { + $sendEmail = 'Novatorius\Fixture\Platform\Actions\SendEmail'; + + $this->analyse([__DIR__ . '/data/PlatformDomainCore.php'], [ + [self::platformMessage($sendEmail), 6], + [self::platformMessage($sendEmail), 12], + ]); + } + + public function testDisabledRuleReportsNothing(): void + { + $this->enabled = false; + + $this->analyse([__DIR__ . '/data/CoreUsesService.php'], []); + } +} diff --git a/tests/Rules/StackElevator/ServiceLayerPurityRuleTest.php b/tests/Rules/StackElevator/ServiceLayerPurityRuleTest.php new file mode 100644 index 0000000..70ba073 --- /dev/null +++ b/tests/Rules/StackElevator/ServiceLayerPurityRuleTest.php @@ -0,0 +1,87 @@ + + */ +class ServiceLayerPurityRuleTest extends RuleTestCase +{ + private bool $enabled = true; + + /** @var list */ + private array $excludeNamespaces = []; + + /** @var list */ + private array $denylist = []; + + protected function getRule(): Rule + { + return new ServiceLayerPurityRule( + new StackElevatorLayerResolver( + namespaceRoots: ['Novatorius', 'PHPNomad'], + coreSegments: ['Core'], + serviceSegments: ['Service'], + platformSegments: ['Platform', 'WordPress', 'Integration'], + excludeNamespaces: $this->excludeNamespaces + ), + enabled: $this->enabled, + denylist: $this->denylist + ); + } + + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/../../phpstan.neon']; + } + + private static function platformMessage(string $symbol): string + { + return sprintf( + 'Service layer must not depend on the Platform layer: %s is Platform-layer code. Stack Elevator: dependencies only point upward (Platform → Service → Core). Define a Core contract and let the Platform layer implement it.', + $symbol + ); + } + + public function testServiceReferencingCoreIsAllowed(): void + { + $this->analyse([__DIR__ . '/data/ServiceUsesCore.php'], []); + } + + public function testServiceReferencingPlatformIsFlagged(): void + { + $adapter = 'Novatorius\Fixture\Contacts\Platform\WordPressContactAdapter'; + $optionStore = 'Novatorius\Fixture\WordPress\OptionStore'; + + $this->analyse([__DIR__ . '/data/ServiceUsesPlatform.php'], [ + [self::platformMessage($adapter), 5], + [self::platformMessage($adapter), 11], + [self::platformMessage($optionStore), 12], + ]); + } + + public function testCoreLayerFilesAreIgnoredByServiceRule(): void + { + $this->analyse([__DIR__ . '/data/CoreUsesService.php'], []); + $this->analyse([__DIR__ . '/data/CoreUsesPlatform.php'], []); + } + + public function testExcludedNamespaceIsGrandfathered(): void + { + $this->excludeNamespaces = ['Novatorius\Fixture\Contacts\Service']; + + $this->analyse([__DIR__ . '/data/ServiceUsesPlatform.php'], []); + } + + public function testDisabledRuleReportsNothing(): void + { + $this->enabled = false; + + $this->analyse([__DIR__ . '/data/ServiceUsesPlatform.php'], []); + } +} diff --git a/tests/Rules/StackElevator/data/CleanCore.php b/tests/Rules/StackElevator/data/CleanCore.php new file mode 100644 index 0000000..4ba51ae --- /dev/null +++ b/tests/Rules/StackElevator/data/CleanCore.php @@ -0,0 +1,14 @@ +service = $service; + } + + public function make(): Contact + { + $formatter = new \Novatorius\Fixture\Contacts\Service\Helpers\Formatter(); + ContactService::warm(); + + return new Contact(); + } +} diff --git a/tests/Rules/StackElevator/data/CoreUsesThirdParty.php b/tests/Rules/StackElevator/data/CoreUsesThirdParty.php new file mode 100644 index 0000000..9890cd9 --- /dev/null +++ b/tests/Rules/StackElevator/data/CoreUsesThirdParty.php @@ -0,0 +1,14 @@ +