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
2 changes: 2 additions & 0 deletions docs/available-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`.
| Rule | Constructor | Checks |
|---|---|---|
| `AnonymousClassMayNotHaveEmptyParenthesesRule` | `new AnonymousClassMayNotHaveEmptyParenthesesRule(layer: 'Source')` | Anonymous classes that pass no constructor argument omit the parentheses after `class` (`new class {}`, not `new class () {}`), per [PER Coding Style](https://www.php-fig.org/per/coding-style/#8-anonymous-classes). Supports `--fix` by removing the empty parentheses. |
| `ApiMayNotBeInternalRule` | `new ApiMayNotBeInternalRule(layer: 'Source')` | Class-likes in a layer are not tagged both `@api` and `@internal`, which contradict each other. |
| `ApiMayNotExtendInternalRule` | `new ApiMayNotExtendInternalRule(layer: 'Source')` | Classes and interfaces tagged `@api` in a layer do not extend a scanned class or interface tagged `@internal`, directly or through any parent, which would expose its members through the public API. Unscanned parents are skipped, as their docblock is unknown. |
| `ClassConstantNameMustBeUpperCaseRule` | `new ClassConstantNameMustBeUpperCaseRule(layer: 'Domain')` | Class, interface, and trait constants use upper case with underscore separators. Enums are skipped (PER Coding Style recommends PascalCase enum constants). |
| `ClassImplementingInterfaceMustHaveSuffixRule` | `new ClassImplementingInterfaceMustHaveSuffixRule(layer: 'HTTP', interface: MiddlewareInterface::class, suffix: 'Middleware')` | Classes implementing a specific interface use the required suffix. |
| `ClassNameMustBeStudlyCapsRule` | `new ClassNameMustBeStudlyCapsRule(layer: 'Source')` | Class names use StudlyCaps. |
Expand Down
1 change: 1 addition & 0 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ vendor/bin/structarmed init --preset=mvc
vendor/bin/structarmed init --preset=ddd
vendor/bin/structarmed init --preset=yagni
vendor/bin/structarmed init --preset=codequality
vendor/bin/structarmed init --preset=stability
vendor/bin/structarmed init --preset=all
```

Expand Down
50 changes: 49 additions & 1 deletion docs/custom-rules-and-presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,54 @@ return Architecture::define()
);
```

## Reading @api And @internal Tags In A Custom Rule

Each `ClassNode` records whether the docblock of its class, interface, trait, or enum declaration carries an `@api` or `@internal` tag:

| Flag | Meaning |
| --- | --- |
| `$classNode->isApi` | The class-like docblock has an `@api` tag |
| `$classNode->isInternal` | The class-like docblock has an `@internal` tag |

A tag counts when it starts a docblock line, also when a description follows, such as `@internal only used by the analyser`. A tag name mentioned within the description does not count, and neither do longer tag names such as `@apiVersion` and `@api-version` or the phpDocumentor inline `{@internal ...}}` form, which marks part of the description as internal rather than the class-like itself. Member docblocks are not read.

The built-in `ApiMayNotBeInternalRule` and `ApiMayNotExtendInternalRule` read both flags. The example below enforces that every class-like in a layer declares one of the two tags:

```php
<?php

namespace App\Architecture\Rules;

use Boundwize\StructArmed\Analyser\ClassNode;
use Boundwize\StructArmed\Rule\RuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

use function sprintf;

final readonly class ClassMustDeclareApiOrInternalRule implements RuleInterface
{
public function appliesTo(ClassNode $classNode): bool
{
return $classNode->isInLayer('Core');
}

public function evaluate(ClassNode $classNode): ?RuleViolation
{
if ($classNode->isApi || $classNode->isInternal) {
return null;
}

return new RuleViolation(
message: sprintf('%s [%s] must be annotated @api or @internal', $classNode->getType(), $classNode->className),
file: $classNode->file,
line: $classNode->line,
className: $classNode->className,
layer: $classNode->layer,
);
}
}
```

## Reading Usage Flags In A Custom Rule

Usage flags are opt-in because collecting them costs extra analysis time. Before reading a usage flag, a custom rule must declare that it needs the data by implementing the corresponding marker interface instead of the plain `Boundwize\StructArmed\Rule\RuleInterface`. Each marker extends `RuleInterface`, so no other change is needed:
Expand Down Expand Up @@ -205,7 +253,7 @@ Trade-off: only usage within the scanned paths is known. A class-like used solel

## Reading Other Classes' Layers In A Custom Rule

A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name, and `getDependencyNode()` looks a class up in it:
A rule only receives the node it is evaluating. When a check depends on the layer of *another* class — the layer a dependency belongs to, for instance — extend `Boundwize\StructArmed\Rule\AbstractLayerAwareRule`. Before any class is evaluated, the analyser injects the map of every scanned class into its protected `$classNodeMap` property, keyed by fully qualified class name, and `getDependencyNode()` looks a class up in it, ignoring case as PHP does for class names:

```php
<?php
Expand Down
3 changes: 3 additions & 0 deletions docs/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ StructArmed ships with presets for common PHP standards and architecture styles.
| `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions |
| `Preset::YAGNI()` | Speculative-abstraction cleanup: interfaces must be implemented by a class or extended by another interface, abstract classes must be extended, traits must be used, extended classes that are never instantiated must be abstract, and named functions must be called — a dependency reference (type hint, `instanceof`, `::class`, static call, a class-name string, ...) also counts as usage within the scanned paths, while only instantiation (`new X`, `new self`/`static`/`parent`, or a constant class expression such as `new (X::class)`) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier |
| `Preset::CODEQUALITY()` | General readability conventions independent of any architecture style: closures and arrow functions that do not read `$this` must be declared `static`, and plain decimal numeric literals of `1_000_000` or more must group their digits with `_` separators (`1000500` becomes `1_000_500`). Both rules support `--fix`. Tune the literal threshold with `replaceRule(CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR, new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000))` |
| `Preset::STABILITY()` | Keeps the stable `@api` surface apart from `@internal` code: a class-like must not be tagged both `@api` and `@internal`, and an `@api` class or interface must not extend a scanned `@internal` one, directly or through any parent. Class-likes without either tag are not checked |

## Initialize Presets

Expand All @@ -41,6 +42,7 @@ vendor/bin/structarmed init --preset=mvc
vendor/bin/structarmed init --preset=ddd
vendor/bin/structarmed init --preset=yagni
vendor/bin/structarmed init --preset=codequality
vendor/bin/structarmed init --preset=stability
vendor/bin/structarmed init --preset=all
```

Expand All @@ -58,6 +60,7 @@ return Architecture::define()
Preset::DDD(),
Preset::YAGNI(),
Preset::CODEQUALITY(),
Preset::STABILITY(),
);
```

Expand Down
4 changes: 4 additions & 0 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ vendor/bin/structarmed init --preset=yagni
# Static closures and digit separators in large numeric literals
vendor/bin/structarmed init --preset=codequality

# @api class-likes kept apart from @internal ones
vendor/bin/structarmed init --preset=stability

# Enable every preset at once
vendor/bin/structarmed init --preset=all
```
Expand Down Expand Up @@ -112,5 +115,6 @@ return Architecture::define()
Preset::DDD(),
Preset::CODEQUALITY(),
Preset::YAGNI(),
Preset::STABILITY(),
)
```
17 changes: 17 additions & 0 deletions src/Analyser/AnalysisNodeCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Boundwize\StructArmed\Util\PhpParser\ObjectBoundAnonymousFunction;
use Boundwize\StructArmed\Util\PhpParser\UnconditionallyDeclaredFunctions;
use Boundwize\StructArmed\Util\PhpParser\VisibilityFlagChecker;
use PhpParser\Comment\Doc;
use PhpParser\ConstExprEvaluationException;
use PhpParser\ConstExprEvaluator;
use PhpParser\Node;
Expand Down Expand Up @@ -1689,6 +1690,8 @@ enumBackingType: $classLike instanceof Enum_ && $classLike->scalarType instan
: null,
nonClassDependencies: $analysis['nonClassDependencies'],
constantFetches: $analysis['constantFetches'],
isApi: $this->hasDocBlockTag($classLike, 'api'),
isInternal: $this->hasDocBlockTag($classLike, 'internal'),
);
}

Expand Down Expand Up @@ -1829,6 +1832,20 @@ private function resolveClassName(ClassLike $classLike): string
: (string) $classLike->name;
}

/**
* Whether the docblock carries the $tag tag, e.g. `@api`. A tag starts a
* docblock line, so the tag name mentioned in the description, a longer
* tag such as `@api-version`, or an inline `{@internal ...}` comment does
* not count.
*/
private function hasDocBlockTag(ClassLike $classLike, string $tag): bool
{
$docComment = $classLike->getDocComment();

return $docComment instanceof Doc
&& preg_match('#^[/*\s]*@' . $tag . '(?![\w-])#m', $docComment->getText()) === 1;
}

/**
* @return array{
* dependencies: list<string>,
Expand Down
4 changes: 4 additions & 0 deletions src/Analyser/ClassNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ final class ClassNode
* @param string|null $enumBackingType Backing type for a backed enum, null otherwise
* @param list<string> $nonClassDependencies Dependencies only ever used as a function or constant name
* @param list<string> $constantFetches Global and namespaced constants fetched within this class
* @param bool $isApi Whether the docblock marks it as @api
* @param bool $isInternal Whether the docblock marks it as @internal
*/
public function __construct(
public readonly string $className,
Expand Down Expand Up @@ -75,6 +77,8 @@ public function __construct(
public readonly ?string $enumBackingType = null,
public readonly array $nonClassDependencies = [],
public readonly array $constantFetches = [],
public readonly bool $isApi = false,
public readonly bool $isInternal = false,
) {
$this->layers = $layers ?: array_filter([$this->layer]);
}
Expand Down
20 changes: 17 additions & 3 deletions src/Cache/AnalysisResultCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ final class AnalysisResultCache
* their shape, naming, or reference semantics change. It is recorded in
* the metadata marker, so older caches are cleared on their next use.
*/
public const FORMAT_VERSION = 17;
public const FORMAT_VERSION = 18;

private readonly string $cacheDirectory;

Expand Down Expand Up @@ -976,8 +976,8 @@ private function functionLikeBodyFromArray(array $node, string $file): ?array

/**
* The file is not stored: the payload belongs to one file, known when
* loading. Empty lists — most of a typical class's — are left out and
* default on load.
* loading. Empty lists — most of a typical class's — and the rarely set
* isApi and isInternal flags are left out and default on load.
*
* @return array<string, mixed>
*/
Expand All @@ -997,6 +997,14 @@ private function classNodeToArray(ClassNode $classNode): array
'enumBackingType' => $classNode->enumBackingType,
];

if ($classNode->isApi) {
$node['isApi'] = true;
}

if ($classNode->isInternal) {
$node['isInternal'] = true;
}

$lists = [
'dependencies' => $classNode->dependencies,
'nonClassDependencies' => $classNode->nonClassDependencies,
Expand Down Expand Up @@ -1040,6 +1048,8 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode
$isTrait = $node['isTrait'] ?? null;
$isEnum = $node['isEnum'] ?? null;
$isReadonly = $node['isReadonly'] ?? null;
$isApi = $node['isApi'] ?? false;
$isInternal = $node['isInternal'] ?? false;
$dependencies = $node['dependencies'] ?? [];
$nonClassDependencies = $node['nonClassDependencies'] ?? [];
$constantFetches = $node['constantFetches'] ?? [];
Expand All @@ -1065,6 +1075,8 @@ private function classNodeFromArray(array $node, string $file): ?ClassNode
|| ! is_bool($isTrait)
|| ! is_bool($isEnum)
|| ! is_bool($isReadonly)
|| ! is_bool($isApi)
|| ! is_bool($isInternal)
|| ! $this->isStringArray($dependencies)
|| ! $this->isStringArray($nonClassDependencies)
|| ! $this->isStringArray($constantFetches)
Expand Down Expand Up @@ -1125,6 +1137,8 @@ enumCases: $enumCases,
enumBackingType: $enumBackingType,
nonClassDependencies: array_values($nonClassDependencies),
constantFetches: array_values($constantFetches),
isApi: $isApi,
isInternal: $isInternal,
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/Cli/InitCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ private function presetConfig(string $preset): ?string
'psr15' => ' ->withPreset(Preset::PSR15());',
'yagni' => ' ->withPreset(Preset::YAGNI());',
'codequality' => ' ->withPreset(Preset::CODEQUALITY());',
'stability' => ' ->withPreset(Preset::STABILITY());',
'all' => " ->withPresets(\n"
. " Preset::PSR4(),\n"
. " Preset::PSR1(),\n"
Expand All @@ -99,7 +100,8 @@ private function presetConfig(string $preset): ?string
. " Preset::DDD(),\n"
. " Preset::MVC(),\n"
. " Preset::YAGNI(),\n"
. " Preset::CODEQUALITY()\n"
. " Preset::CODEQUALITY(),\n"
. " Preset::STABILITY()\n"
. " );",
default => null,
};
Expand Down
2 changes: 1 addition & 1 deletion src/Cli/Usage.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public static function render(): string
return <<<'TXT'
Usage:
structarmed --version
structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|codequality|all]
structarmed init [--preset=ddd|mvc|psr4|psr1|psr12|per|psr15|yagni|codequality|stability|all]
structarmed analyse|analyze [path ...] [--config=path/to/structarmed.php]
[-d|--basepath=path/to/project] [--report=console|json|github] [--no-progress]
[--clear-cache] [--no-cache] [--disable-parallel] [--fix] [--generate-baseline=structarmed-baseline.php]
Expand Down
13 changes: 13 additions & 0 deletions src/Preset/Preset.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Boundwize\StructArmed\Preset\Presets\Psr15Preset;
use Boundwize\StructArmed\Preset\Presets\Psr1Preset;
use Boundwize\StructArmed\Preset\Presets\Psr4Preset;
use Boundwize\StructArmed\Preset\Presets\StabilityPreset;
use Boundwize\StructArmed\Preset\Presets\YagniPreset;

/**
Expand All @@ -27,6 +28,7 @@
* ->withPreset(Preset::PSR15())
* ->withPreset(Preset::YAGNI())
* ->withPreset(Preset::CODEQUALITY())
* ->withPreset(Preset::STABILITY())
* ->withPresets(Preset::DDD(), Preset::MVC())
*/
final class Preset
Expand Down Expand Up @@ -124,6 +126,17 @@ public static function CODEQUALITY(
);
}

/**
* @param list<string>|null $sourcePaths
*/
public static function STABILITY(
?array $sourcePaths = null,
): StabilityPreset {
return new StabilityPreset(
sourcePaths: $sourcePaths,
);
}

public static function MVC(
int $controllerMaxComplexity = 5,
int $controllerMaxMethodLength = 20,
Expand Down
50 changes: 50 additions & 0 deletions src/Preset/Presets/StabilityPreset.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Boundwize\StructArmed\Preset\Presets;

use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Preset\PresetInterface;
use Boundwize\StructArmed\Rule\Rules\Class_\ApiMayNotBeInternalRule;
use Boundwize\StructArmed\Rule\Rules\Class_\ApiMayNotExtendInternalRule;

/**
* Stability preset: keeps the stable `@api` surface apart from `@internal`
* code — a class-like is not tagged both, and an `@api` class or interface
* does not extend an `@internal` one, which would expose its members.
*
* Trade-off: only docblock tags of scanned class-likes are known. Class-likes
* without either tag are not checked.
*/
final readonly class StabilityPreset implements PresetInterface
{
use ResolvesSourceLayerNameTrait;

public const API_MAY_NOT_BE_INTERNAL = 'stability.api.may_not_be_internal';

public const API_MAY_NOT_EXTEND_INTERNAL = 'stability.api.may_not_extend_internal';

/**
* @param list<string>|null $sourcePaths
*/
public function __construct(
private ?array $sourcePaths = null,
) {
}

public function apply(Architecture $architecture): void
{
$layerName = $this->resolveLayerName($architecture);
$architecture->layer($layerName, $this->sourcePaths ?? []);

$architecture->rule(
self::API_MAY_NOT_BE_INTERNAL,
new ApiMayNotBeInternalRule($layerName)
);
$architecture->rule(
self::API_MAY_NOT_EXTEND_INTERNAL,
new ApiMayNotExtendInternalRule($layerName)
);
}
}
19 changes: 16 additions & 3 deletions src/Rule/AbstractLayerAwareRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

use Boundwize\StructArmed\Analyser\ClassNode;

use function array_change_key_case;
use function strtolower;

/**
* A rule that needs the layer of a class other than the one under evaluation,
* such as the layer a dependency belongs to. The analyser injects the scanned
Expand All @@ -16,15 +19,25 @@ abstract class AbstractLayerAwareRule
/** @var array<string, ClassNode> class name → class node */
protected array $classNodeMap = [];

/** @var array<string, ClassNode> lowercased class name → class node */
private array $lowercaseClassNodeMap = [];

/** @param array<string, ClassNode> $classNodeMap */
public function injectClassNodeMap(array $classNodeMap): void
{
$this->classNodeMap = $classNodeMap;
$this->classNodeMap = $classNodeMap;
$this->lowercaseClassNodeMap = array_change_key_case($classNodeMap);
}

/** The scanned node of a dependency, or null when it lies outside the scanned paths */
/**
* The scanned node of a dependency, or null when it lies outside the
* scanned paths. Class names are case-insensitive, so `extends internalbase`
* still finds the node declared as InternalBase.
*/
protected function getDependencyNode(string $dependency): ?ClassNode
{
return $this->classNodeMap[$dependency] ?? null;
return $this->classNodeMap[$dependency]
?? $this->lowercaseClassNodeMap[strtolower($dependency)]
?? null;
}
}
Loading
Loading