Skip to content

Released: StructArmed 0.17.0

Choose a tag to compare

@samsonasik samsonasik released this 04 Sep 02:28
· 23 commits to main since this release
0.17.0
5536c6d

ci build PHPStan

StructArmed 0.17.0 expands architecture analysis beyond named classes.

This release introduces dedicated analysis nodes and rule interfaces for:

  • named functions;
  • closures and arrow functions;
  • anonymous classes.

It also introduces the new PER Coding Style and Code Quality presets, expands the MVC and DDD presets, adds several fixable coding-style rules.

New Rule Interfaces

Three new interfaces allow custom rules to target a specific kind of PHP declaration:

  • Boundwize\StructArmed\Rule\FunctionRuleInterface
  • Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface
  • Boundwize\StructArmed\Rule\AnonymousClassRuleInterface

Each interface uses the same appliesTo() and evaluate() method names as the existing RuleInterface, but receives a node containing information specific to that declaration type.

Interface Node Analyses
FunctionRuleInterface FunctionNode Named functions
AnonymousFunctionRuleInterface AnonymousFunctionNode Closures and arrow functions
AnonymousClassRuleInterface AnonymousClassNode Anonymous classes

These nodes expose information such as their source file, line, layer, dependencies, function calls, superglobal access, language constructs, parameters, return types, complexity, and line count.

Anonymous-function nodes additionally report whether the declaration:

  • is a closure or arrow function;
  • is already static;
  • accesses $this;
  • belongs to a named class or function.

Anonymous-class nodes include:

  • their extended class and implemented interfaces;
  • traits and members;
  • transitive parent classes and interfaces;
  • constructor parameter count;
  • readonly status;
  • whether empty constructor parentheses were written.

Named Function Rules

The new MustHaveReturnTypeFunctionRule requires named functions in a configured layer to declare a return type.

It is enabled for the MVC preset's Helper layer.

-function format_price(int $amount)
+function format_price(int $amount): string
 {
     return number_format($amount);
 }

This complements the existing method return-type rules: standalone helper functions can now be checked independently from class methods.

Closures And Arrow Functions

The new MustBeStaticAnonymousFunctionRule detects closures and arrow functions that do not access $this but have not been declared static.

-$activeUsers = array_filter($users, function (User $user): bool {
+$activeUsers = array_filter($users, static function (User $user): bool {
     return $user->isActive();
 });

Arrow functions are supported as well:

-$ids = array_map(fn (User $user): int => $user->id, $users);
+$ids = array_map(static fn (User $user): int => $user->id, $users);

Closures that read $this, directly or through a nested closure, are skipped because PHP does not allow $this inside a static closure.

This rule supports --fix.

Anonymous Class Analysis

Anonymous classes now have their own AnonymousClassNode representation and rule interface.

Their class members, dependencies, traits, readonly status, and parent hierarchy are collected just like those of named classes. Consequently, methods such as extendsClass() and implementsInterface() work across direct and transitive parents.

The new fixable AnonymousClassMayNotHaveEmptyParenthesesRule implements the PER convention that an anonymous class passing no constructor arguments should omit empty parentheses:

-$handler = new class () implements Handler {
+$handler = new class implements Handler {
     public function handle(): void
     {
     }
 };

Parentheses containing actual constructor arguments are unaffected.

New PER Coding Style Preset

The new Preset::PER() implements additional rules from the PER Coding Style and includes the existing PSR-12 rules.

Enable it in structarmed.php:

 return Architecture::define()
-    ->withPresets(Preset::PSR4(), Preset::PSR12());
+    ->withPreset(Preset::PER());

In addition to PSR-12, the PER preset checks the following conventions.

Enum Cases Must Use PascalCase

 enum OrderStatus
 {
-    case pending_payment;
+    case PendingPayment;
 }

Enum Methods May Not Be Protected

Enums cannot be extended, so protected methods should be private:

 enum OrderStatus
 {
-    protected function label(): string
+    private function label(): string
     {
         return $this->name;
     }
 }

Enum Constants May Not Be Protected

 enum OrderStatus
 {
-    protected const DEFAULT_LABEL = 'Unknown';
+    private const DEFAULT_LABEL = 'Unknown';
 }

Anonymous Classes May Not Have Empty Parentheses

-$object = new class () {};
+$object = new class {};

The enum visibility and anonymous-class-parentheses rules support --fix.

Lowercase PHP Keyword Constants

The PSR-12 preset now includes the fixable MustUseLowercaseKeywordConstantRule.

It requires the PHP keyword constants true, false, and null to use their canonical lowercase spelling:

-$enabled = TRUE;
-$disabled = FALSE;
-$value = NULL;
+$enabled = true;
+$disabled = false;
+$value = null;

Only the spelling is changed. For example, a fully qualified \TRUE becomes \true.

New Code Quality Preset

The new Preset::CODEQUALITY() provides readability rules that are independent of a particular architecture style or coding standard.

Enable it alongside other presets:

 return Architecture::define()
     ->withPresets(
         Preset::DDD(),
+        Preset::CODEQUALITY(),
     );

Anonymous Functions Must Be Static

Closures and arrow functions that do not use $this must be declared static.

-$names = array_map(fn (User $user) => $user->name, $users);
+$names = array_map(static fn (User $user) => $user->name, $users);

Declaring these functions static makes it explicit that they do not capture the enclosing object.

Large Numeric Literals Must Use Separators

Plain decimal numeric literals of at least 1_000_000 must group their digits using _ separators:

-$maximumUploadSize = 10000000;
+$maximumUploadSize = 10_000_000;

Decimal fractions retain their fractional portion:

-$amount = 1000500.75;
+$amount = 1_000_500.75;

The default threshold can be customized by replacing the preset rule:

<?php

use Boundwize\StructArmed\Architecture;
use Boundwize\StructArmed\Preset\Preset;
use Boundwize\StructArmed\Preset\Presets\CodeQualityPreset;
use Boundwize\StructArmed\Rule\Rules\File\LargeNumericLiteralMustUseSeparatorRule;

return Architecture::define()
    ->withPreset(Preset::CODEQUALITY())
    ->replaceRule(
        CodeQualityPreset::LARGE_NUMERIC_LITERALS_MUST_USE_SEPARATOR,
        new LargeNumericLiteralMustUseSeparatorRule(minimum: 1_000),
    );

Both Code Quality rules support --fix.

DDD Preset: Prevent Infrastructure Inheritance

The new MayNotExtendClassRule prevents classes in a layer from extending a configured class, either directly or through a parent class.

The DDD preset uses it to prevent Domain classes from extending Doctrine's infrastructure-oriented EntityRepository:

 namespace App\Domain\Repository;

-use Doctrine\ORM\EntityRepository;
-
-final class OrderRepository extends EntityRepository
+interface OrderRepository
 {
 }

A custom rule can enforce the same boundary for another framework base class:

use Boundwize\StructArmed\Rule\Rules\Class_\MayNotExtendClassRule;

return Architecture::define()
    ->layer('Domain', 'src/Domain/')
    ->rule(
        'domain.must_not_extend_eloquent_model',
        new MayNotExtendClassRule(
            layer: 'Domain',
            class: 'Illuminate\Database\Eloquent\Model',
        ),
    );

Writing A Custom Function Rule

For example, the following rule prevents named functions in the Domain layer from reading PHP superglobals:

<?php

namespace App\Architecture\Rules;

use Boundwize\StructArmed\Analyser\FunctionNode;
use Boundwize\StructArmed\Rule\FunctionRuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

use function sprintf;

final readonly class FunctionsMustNotAccessSuperglobalsRule implements FunctionRuleInterface
{
    public function appliesTo(FunctionNode $functionNode): bool
    {
        return $functionNode->isInLayer('Domain');
    }

    public function evaluate(FunctionNode $functionNode): ?RuleViolation
    {
        if (! $functionNode->accessesSuperglobals()) {
            return null;
        }

        return new RuleViolation(
            message: sprintf(
                'Function [%s()] must not access superglobals',
                $functionNode->functionName,
            ),
            file:         $functionNode->file,
            line:         $functionNode->line,
            className:    $functionNode->functionName,
            layer:        $functionNode->layer,
            functionName: $functionNode->functionName,
        );
    }
}

Register it like any other rule:

return Architecture::define()
    ->layer('Domain', 'src/Domain/')
    ->rule(
        'domain.functions_must_not_access_superglobals',
        new FunctionsMustNotAccessSuperglobalsRule(),
    );

Global skip paths, rule-scoped skip() paths, and skipRule() also apply to function, anonymous-function, and anonymous-class rules.

Custom Rule Migration Notes

Layer-aware rules now extend AbstractLayerAwareRule instead of implementing LayerAwareRuleInterface.

-use Boundwize\StructArmed\Rule\LayerAwareRuleInterface;
+use Boundwize\StructArmed\Rule\AbstractLayerAwareRule;

-final class DomainDependencyRule implements RuleInterface, LayerAwareRuleInterface
+final class DomainDependencyRule extends AbstractLayerAwareRule implements RuleInterface
 {
 }

The base class provides the class-node map injection and getDependencyNode() lookup used to inspect the layer of another scanned class.

Token-aware fixer visitors similarly use AbstractTokenAwareVisitor instead of TokenAwareVisitorInterface.

Several internal extractor and parallel-worker classes were also renamed from ClassNode* to AnalysisNode* to reflect their expanded support for functions and anonymous classes.

What's Changed

  • refactor: Rename ClassNode extractor/worker classes to AnalysisNode* to prepare for non-class node support by @samsonasik in #363
  • feat: Add MayNotExtendClassRule by @samsonasik in #366
  • feat: Add FunctionNode and AnonymousFunctionNode with function-like rule interfaces by @samsonasik in #367
  • perf: Optimize AnalysisNodeCollector traversal and layer resolution on AnalysisNodeCollector by @samsonasik in #368
  • perf: Reduce analysis cache I/O overhead by @samsonasik in #370
  • perf: skip getMethods() pre-pass, resolve method's class analysis on ClassMethod enter by @samsonasik in #371
  • Add MustHaveReturnTypeFunctionRule by @samsonasik in #376
  • Register MayNotExtendClassRule to DDD preset by @samsonasik in #378
  • refactor: Unify rule interfaces under shared appliesTo()/evaluate() method names with a single evaluation loop by @samsonasik in #382
  • perf: Evaluate node rules per node kind via pre-grouped (nodes, rules) pairs by @samsonasik in #383
  • perf: precompute trailing-slash layer-path prefixes in NamespaceLayerResolver for single str_starts_with matching by @samsonasik in #384
  • refactor: Reuse NodeQueryTrait (renamed from FunctionLikeNodeTrait) in ClassNode by @samsonasik in #385
  • feat: add Preset::PER() extending PSR-12 with EnumCaseNameMustBePascalCaseRule by @samsonasik in #386
  • feat: add EnumMethodMayNotBeProtectedRule and EnumConstantMayNotBeProtectedRule to PER preset by @samsonasik in #388
  • feat: add fixable MustUseLowercaseKeywordConstantRule for true/false/null spelling by @samsonasik in #389
  • perf: Optimize keyword constant collection hot path by @samsonasik in #391
  • perf: Reduce parallel worker cache payload size by @samsonasik in #393
  • Register MustUseLowercaseKeywordConstantRule in PSR-12 preset by @samsonasik in #394
  • perf: stream analysis cache file hashing by @samsonasik in #395
  • chore: Make use of Node 24 on github workflows by @samsonasik in #396
  • Add LargeNumericLiteralMustUseSeparatorRule by @samsonasik in #397
  • Add CodeQuality preset with MustBeStaticAnonymousFunctionRule and LargeNumericLiteralMustUseSeparatorRule by @samsonasik in #398
  • chore: sync behaviour of PSR-4 Composer rules for missing or invalid configuration by @samsonasik in #399
  • perf: Omit empty lists and the per-node file path from analysis node cache payloads by @samsonasik in #400
  • perf: Store class members as positional tuples in analysis node cache payloads by @samsonasik in #401
  • chore: Clean up setInstantiated() validation as it post process check already in Analyser by @samsonasik in #402
  • perf: batch PHP fixer violations by rule and file by @samsonasik in #404
  • perf: Only run fixViolations() when rule violation collection is not empty by @samsonasik in #405
  • perf: Dispatch Variable nodes first in AnalysisNodeCollector and collect dependencies as sets by @samsonasik in #406
  • Add AnonymousClassRuleInterface and new AnonymousClassMayNotHaveEmptyParenthesesRule by @samsonasik in #407
  • Resolve the parent chain of anonymous classes so extendsClass() and implementsInterface() work on AnonymousClassNode by @samsonasik in #408
  • Sync AnonymousClassNode with ClassNode: members, body deps, and isReadonly by @samsonasik in #409
  • refactor LayerAwareRuleInterface into AbstractLayerAwareRule by @samsonasik in #410
  • Prepare for 0.17.0 by @samsonasik in #379

Full Changelog: 0.16.31...0.17.0