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
22 changes: 21 additions & 1 deletion docs/available-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,27 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`.

`classNamePattern` and `excludePattern` are regular expressions matched against the fully-qualified class name.

`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustUseLowercaseKeywordConstantRule`, `LargeNumericLiteralMustUseSeparatorRule`, `ExtendedClassMustBeAbstractOrInstantiatedRule`, `MustBeFinalRule`, `MustBeUsedInterfaceRule`, `MustBeUsedAbstractClassRule`, `MustBeUsedTraitRule`, `MustDeclareConstantVisibilityRule`, `MustDeclareMethodVisibilityRule`, and `MustDeclarePropertyVisibilityRule` implement `Boundwize\StructArmed\Rule\FixableInterface`, so StructArmed can automatically remove PSR-4 mappings for missing directories, normalize invalid PHP opening tags, remove UTF-8 byte order marks, lowercase `TRUE`/`FALSE`/`NULL` keyword constants, add `_` separators to large numeric literals, add the `final` or `abstract` class modifier, remove unused interfaces, abstract classes, and traits (deleting their file when only `declare`/`namespace`/`use` boilerplate remains), and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`.
## Fixable Rules

The following rules implement `Boundwize\StructArmed\Rule\FixableInterface` and can apply their changes when you run `vendor/bin/structarmed analyse --fix`.

| Rule | Automatic fix |
|---|---|
| `Psr4DirectoryExistsRule` | Removes PSR-4 mappings for missing directories. |
| `Psr1PhpTagsRule` | Normalizes invalid PHP opening tags. |
| `Psr1Utf8WithoutBomRule` | Removes the UTF-8 byte order mark. |
| `MustUseLowercaseKeywordConstantRule` | Lowercases `TRUE`, `FALSE`, and `NULL` keyword constants. |
| `LargeNumericLiteralMustUseSeparatorRule` | Adds `_` separators to large numeric literals. |
| `AnonymousClassMayNotHaveEmptyParenthesesRule` | Removes empty parentheses from anonymous classes that pass no constructor arguments. |
| `ExtendedClassMustBeAbstractOrInstantiatedRule` | Adds the `abstract` modifier to an extended class that is not instantiated. |
| `MustBeFinalRule` | Adds the `final` modifier. |
| `MustBeUsedInterfaceRule` | Removes an unused interface, deleting its file when only boilerplate remains. |
| `MustBeUsedAbstractClassRule` | Removes an unused abstract class, deleting its file when only boilerplate remains. |
| `MustBeUsedTraitRule` | Removes an unused trait, deleting its file when only boilerplate remains. |
| `MustDeclareConstantVisibilityRule` | Adds a missing constant visibility modifier. |
| `MustDeclareMethodVisibilityRule` | Adds a missing method visibility modifier. |
| `MustDeclarePropertyVisibilityRule` | Adds a missing property visibility modifier. |
{: .rule-table }

## Function Rules

Expand Down
60 changes: 55 additions & 5 deletions docs/custom-rules-and-presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,20 +177,21 @@ The built-in [YAGNI preset](../presets/) rules follow this pattern: `MustBeUsedI

Trade-off: only usage within the scanned paths is known. A class-like used solely by a consumer outside the scan — a vendor package, an unscanned directory, runtime-fed dynamic construction — is reported as if unused. Widen the scan, or use `skipRule()` and skip paths where such consumers exist.

## Analysing Functions And Closures
## Analysing Functions, Closures, And Anonymous Classes

Named functions, closures, and arrow functions are collected alongside classes:
Named functions, closures, arrow functions, and anonymous classes are collected alongside named classes:

| Node | Represents | Identified by |
| --- | --- | --- |
| `Boundwize\StructArmed\Analyser\FunctionNode` | A named function declaration (`function foo() {}`), global or namespaced | `$functionName` (fully qualified) |
| `Boundwize\StructArmed\Analyser\AnonymousFunctionNode` | A closure (`function () {}`) or arrow function (`fn () => ...`) | `$file` and `$line`, plus `$enclosingClassName` / `$enclosingFunctionName` |
| `Boundwize\StructArmed\Analyser\AnonymousClassNode` | An anonymous class declaration (`new class ... {}`) | `$file` and `$line`, plus `$enclosingClassName` / `$enclosingFunctionName` |

Both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functionCalls`, `$superglobals`, `$languageConstructs`, `$layer` / `$layers` — plus `$paramCount`, `$hasReturnType`, `$cyclomaticComplexity`, and `$lineCount`. The same query helpers are available: `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()`. A `FunctionNode` also has `shortName()`, `nameStartsWith()`, `nameEndsWith()`, and `nameMatches()`; an `AnonymousFunctionNode` has `$isArrowFunction`, `$isStatic`, `getType()`, and `enclosingScopeName()`.
`FunctionNode` and `AnonymousFunctionNode` both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functionCalls`, `$superglobals`, `$languageConstructs`, `$layer` / `$layers` — plus `$paramCount`, `$hasReturnType`, `$cyclomaticComplexity`, and `$lineCount`. The same query helpers are available: `isInLayer()`, `dependsOn()`, `dependsOnNamespace()`, `callsFunction()`, `usesLanguageConstruct()`, and `accessesSuperglobals()`. A `FunctionNode` also has `shortName()`, `nameStartsWith()`, `nameEndsWith()`, and `nameMatches()`; an `AnonymousFunctionNode` has `$isArrowFunction`, `$isStatic`, `getType()`, and `enclosingScopeName()`.

A closure declared inside a class or a named function is counted on both nodes: the enclosing `ClassNode` (or `FunctionNode`) keeps seeing everything the closure does, exactly as it sees its own method bodies, and the `AnonymousFunctionNode` reports the closure body on its own.

Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$layer` / `$layers` with `isInLayer()`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's.
Anonymous classes (`new class ... {}`) are collected the same way, as `Boundwize\StructArmed\Analyser\AnonymousClassNode`: identified by `$file` and `$line` plus `$enclosingClassName` / `$enclosingFunctionName` (with `enclosingScopeName()` and `AnonymousClassNode::FILE_SCOPE`), and carrying `$extends`, `$implements`, `$traits`, `$layer` / `$layers` with `isInLayer()`, and `$hasEmptyParentheses` — whether the declaration spells `new class () {}` although it passes no constructor argument. Its parent chain is resolved like a named class's: `$parentClasses` and `$parentInterfaces` hold the direct and transitive parents found in the scanned paths, and `extendsClass()` / `implementsInterface()` answer case-insensitively through that chain, exactly as on a `ClassNode`. An anonymous class never becomes a `ClassNode`; the named class-like or function declaring it keeps seeing its body, exactly as it sees a closure's.

Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface`, `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`, and/or `Boundwize\StructArmed\Rule\AnonymousClassRuleInterface`. All share the `appliesTo()` / `evaluate()` method names with `RuleInterface`, each typed against its own node kind. Global skip paths, rule-scoped `skip()` paths, and `skipRule()` apply the same way. Function-likes and anonymous classes are not part of the declarative `ruleset()` layer-dependency check.

Expand Down Expand Up @@ -279,7 +280,56 @@ final readonly class ClosuresMustNotAccessSuperglobalsRule implements AnonymousF
}
```

One rule class can also implement several of these interfaces at once; PHP then requires the shared methods to widen the parameter to a union type (for example `appliesTo(FunctionNode|AnonymousFunctionNode $node): bool`) and the rule branches on the node type inside.
An anonymous-class rule receives an `AnonymousClassNode` for every anonymous class in the scanned paths. For example, this rule requires anonymous classes in one layer to implement a project-specific marker interface, directly or through the class they extend (`implementsInterface()` walks the resolved parent chain, so `new class extends BaseHandler {}` passes when `BaseHandler` implements the interface):

```php
<?php

namespace App\Architecture\Rules;

use Boundwize\StructArmed\Analyser\AnonymousClassNode;
use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;

use function sprintf;

final readonly class AnonymousClassesMustImplementRule implements AnonymousClassRuleInterface
{
public function __construct(
private string $layer,
private string $interface,
) {
}

public function appliesTo(AnonymousClassNode $anonymousClassNode): bool
{
return $anonymousClassNode->isInLayer($this->layer);
}

public function evaluate(AnonymousClassNode $anonymousClassNode): ?RuleViolation
{
if ($anonymousClassNode->implementsInterface($this->interface)) {
return null;
}

return new RuleViolation(
message: sprintf(
'Anonymous class in [%s] must implement [%s]',
$anonymousClassNode->enclosingScopeName(),
$this->interface,
),
file: $anonymousClassNode->file,
line: $anonymousClassNode->line,
className: $anonymousClassNode->enclosingScopeName(),
layer: $anonymousClassNode->layer,
);
}
}
```

Register it through `Architecture::rule()` like any other custom rule. The analyser invokes it only for anonymous classes because it implements `AnonymousClassRuleInterface`.

One rule class can also implement several of these interfaces at once; PHP then requires the shared methods to widen the parameter to a union type (for example `appliesTo(FunctionNode|AnonymousFunctionNode|AnonymousClassNode $node): bool`) and the rule branches on the node type inside.

`RuleViolation::$className` is required, so a function rule passes the function name there (and, optionally, in the dedicated `functionName` field, which the JSON report emits as `"function"`); an anonymous-function or anonymous-class rule passes `enclosingScopeName()`, which is the enclosing class-like or named function, or `FILE_SCOPE` (`'file scope'`) for one in top-level procedural code.

Expand Down
91 changes: 73 additions & 18 deletions src/Analyser/Analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ public function analyse(
$withFileAnalysis,
);
$classNodes = $extractionResult->classNodes;
$classNodes = $this->withRecursiveParents($classNodes);
$classNodes = $this->withRecursiveParents($classNodes, $extractionResult->anonymousClassNodes);

if ($hasExtendedClassAwareRule || $hasUsedInterfaceAwareRule || $hasUsedTraitAwareRule) {
$this->markClassLikeUsage(
Expand Down Expand Up @@ -1051,10 +1051,11 @@ private function collectTraitUsers(
}

/**
* @param list<ClassNode> $classNodes
* @param list<ClassNode> $classNodes
* @param list<AnonymousClassNode> $anonymousClassNodes
* @return list<ClassNode>
*/
private function withRecursiveParents(array $classNodes): array
private function withRecursiveParents(array $classNodes, array $anonymousClassNodes): array
{
$parentClassMap = [];
$parentInterfaceMap = [];
Expand Down Expand Up @@ -1093,13 +1094,32 @@ private function withRecursiveParents(array $classNodes): array
$classNode->setRecursiveParents($result['classes'], $result['interfaces']);
}

// An anonymous class is never a parent, so it is absent from the maps and
// starts the DFS from its own `extends`/`implements` clauses instead.
foreach ($anonymousClassNodes as $anonymousClassNode) {
if ($anonymousClassNode->extends === null && $anonymousClassNode->implements === []) {
continue;
}

$cycleDetected = false;
$result = $this->collectRecursiveParents(
$anonymousClassNode->extends !== null ? [$anonymousClassNode->extends] : [],
$anonymousClassNode->implements,
$parentClassMap,
$parentInterfaceMap,
$parentsCache,
[],
$cycleDetected
);

$anonymousClassNode->setRecursiveParents($result['classes'], $result['interfaces']);
}

return $classNodes;
}

/**
* Single DFS that collects both ancestor classes and transitively implemented/extended
* interfaces in one pass, avoiding the double traversal of the parent-class chain that
* the previous two-method approach required.
* Cached, name-keyed entry point to the parent-chain DFS for a scanned class-like.
*
* @param array<string, list<string>> $parentClassMap
* @param array<string, list<string>> $parentInterfaceMap
Expand All @@ -1119,11 +1139,54 @@ private function recursiveParents(
return $cache[$classNameKey];
}

$hasCycle = false;
$result = $this->collectRecursiveParents(
$parentClassMap[$classNameKey] ?? [],
$parentInterfaceMap[$classNameKey] ?? [],
$parentClassMap,
$parentInterfaceMap,
$cache,
$seen,
$hasCycle
);

if (! $hasCycle) {
$cache[$classNameKey] = $result;
}

$cycleDetected = $cycleDetected || $hasCycle;

return $result;
}

/**
* Single DFS that collects both ancestor classes and transitively implemented/extended
* interfaces in one pass, avoiding the double traversal of the parent-class chain that
* the previous two-method approach required. Seeded with a node's direct parents so an
* anonymous class, which has no name to look up in the maps, resolves its chain the
* same way a named class does.
*
* @param string[] $parentClasses
* @param string[] $parentInterfaces
* @param array<string, list<string>> $parentClassMap
* @param array<string, list<string>> $parentInterfaceMap
* @param array<string, array{classes: list<string>, interfaces: list<string>}> $cache
* @param array<string, true> $seen
* @return array{classes: list<string>, interfaces: list<string>}
*/
private function collectRecursiveParents(
array $parentClasses,
array $parentInterfaces,
array $parentClassMap,
array $parentInterfaceMap,
array &$cache,
array $seen,
bool &$hasCycle
): array {
$classesSet = [];
$interfacesSet = [];
$hasCycle = false;

foreach ($parentClassMap[$classNameKey] ?? [] as $parentClass) {
foreach ($parentClasses as $parentClass) {
$parentClassKey = strtolower($parentClass);

if (isset($seen[$parentClassKey])) {
Expand Down Expand Up @@ -1153,7 +1216,7 @@ private function recursiveParents(
$hasCycle = $hasCycle || $childHasCycle;
}

foreach ($parentInterfaceMap[$classNameKey] ?? [] as $parentInterface) {
foreach ($parentInterfaces as $parentInterface) {
$parentInterfaceKey = strtolower($parentInterface);

if (isset($seen[$parentInterfaceKey])) {
Expand All @@ -1179,18 +1242,10 @@ private function recursiveParents(
$hasCycle = $hasCycle || $childHasCycle;
}

$result = [
return [
'classes' => array_keys($classesSet),
'interfaces' => array_keys($interfacesSet),
];

if (! $hasCycle) {
$cache[$classNameKey] = $result;
}

$cycleDetected = $cycleDetected || $hasCycle;

return $result;
}

/**
Expand Down
39 changes: 22 additions & 17 deletions src/Analyser/AnonymousClassNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
namespace Boundwize\StructArmed\Analyser;

use function array_filter;
use function in_array;

/**
* An anonymous class declaration (`new class ... {}`). Anonymous classes never
Expand All @@ -17,17 +16,24 @@
* The class it extends, the interfaces it implements, and the traits it uses
* are still used within the scanned paths, which usage-aware rules must take
* into account: MustBeFinalRule must skip a class extended by an anonymous class.
*
* Its parent chain is resolved by the analyser like a named class's, so
* {@see extendsClass()} and {@see implementsInterface()} see transitive
* parents too.
*/
final readonly class AnonymousClassNode
final class AnonymousClassNode
{
use LayerQueryTrait;
use RecursiveParentsTrait;

/**
* Scope label reported by {@see enclosingScopeName()} for an anonymous
* class declared outside any class-like or named function.
*/
public const FILE_SCOPE = 'file scope';

/** @var list<string> */
public array $layers;
public readonly array $layers;

/**
* @param string[] $implements Interface names this anonymous class implements
Expand All @@ -37,27 +43,26 @@
* @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument
* is passed: `new class () {}` rather than `new class {}`
* @param list<string> $layers Layer names this anonymous class belongs to; defaults to [$layer]
* @param list<string> $parentClasses Direct and transitive parent class names
* @param list<string> $parentInterfaces Direct and transitive implemented interface names
*/
public function __construct(
public string $file,
public int $line,
public ?string $extends,
public array $implements = [],
public array $traits = [],
public ?string $layer = null,
public ?string $enclosingClassName = null,
public ?string $enclosingFunctionName = null,
public bool $hasEmptyParentheses = false,
public readonly string $file,
public readonly int $line,
public readonly ?string $extends,
public readonly array $implements = [],
public readonly array $traits = [],
public readonly ?string $layer = null,
public readonly ?string $enclosingClassName = null,
public readonly ?string $enclosingFunctionName = null,
public readonly bool $hasEmptyParentheses = false,
array $layers = [],
public array $parentClasses = [],
public array $parentInterfaces = [],
) {
$this->layers = $layers ?: array_filter([$this->layer]);
}

public function isInLayer(string $layer): bool
{
return in_array($layer, $this->layers, true);
}

/**
* Label of the innermost named scope declaring this anonymous class —
* the enclosing class-like, else the enclosing named function — or
Expand Down
Loading