Skip to content
Merged
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
43 changes: 43 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
"PHPNomad\\PhpstanRules\\Tests\\": "tests/"
},
"classmap": [
"tests/stubs/"
"tests/stubs/",
"tests/Rules/StackElevator/data/"
]
},
"extra": {
Expand Down
56 changes: 56 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 15 additions & 0 deletions lib/Enums/StackLayer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace PHPNomad\PhpstanRules\Enums;

/**
* The three layers of the Stack Elevator architecture.
*
* Dependencies only point upward: Platform → Service → Core.
*/
enum StackLayer
{
case Core;
case Service;
case Platform;
}
103 changes: 103 additions & 0 deletions lib/Rules/StackElevator/CoreLayerPurityRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace PHPNomad\PhpstanRules\Rules\StackElevator;

use PhpParser\Node;
use PHPNomad\PhpstanRules\Enums\StackLayer;
use PHPNomad\PhpstanRules\Services\StackElevatorLayerResolver;
use PHPNomad\PhpstanRules\Services\StackElevatorNameExtractor;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* Stack Elevator: the Core layer holds pure business concepts and must
* stay framework-agnostic. Core code must not import or reference
* Service-layer or Platform-layer symbols — dependencies only point
* upward (Platform → Service → Core).
*
* @implements Rule<Node>
*/
class CoreLayerPurityRule implements Rule
{
/**
* @param list<string> $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;
}
}
93 changes: 93 additions & 0 deletions lib/Rules/StackElevator/ServiceLayerPurityRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
<?php

namespace PHPNomad\PhpstanRules\Rules\StackElevator;

use PhpParser\Node;
use PHPNomad\PhpstanRules\Enums\StackLayer;
use PHPNomad\PhpstanRules\Services\StackElevatorLayerResolver;
use PHPNomad\PhpstanRules\Services\StackElevatorNameExtractor;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
* Stack Elevator: the Service layer implements business logic without
* knowing which platform it runs on. Service code must not import or
* reference Platform-layer symbols — dependencies only point upward
* (Platform → Service → Core).
*
* @implements Rule<Node>
*/
class ServiceLayerPurityRule implements Rule
{
/**
* @param list<string> $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;
}
}
Loading
Loading