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
1 change: 1 addition & 0 deletions docs/available-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ 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. |
| `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
8 changes: 5 additions & 3 deletions docs/custom-rules-and-presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,9 @@ Both carry the body-level facts a `ClassNode` has — `$dependencies`, `$functio

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.

Rules opt in to these nodes by implementing `Boundwize\StructArmed\Rule\FunctionRuleInterface` and/or `Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface`. Both 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 are not part of the declarative `ruleset()` layer-dependency check.
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.

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.

```php
<?php
Expand Down Expand Up @@ -279,7 +281,7 @@ 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.

`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 rule passes `enclosingScopeName()`, which is the enclosing class-like or named function, or `AnonymousFunctionNode::FILE_SCOPE` (`'file scope'`) for a closure in top-level procedural code.
`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.

## Making A Custom Rule Fixable

Expand Down Expand Up @@ -395,6 +397,6 @@ return Architecture::define()

Use `rule()` when one project needs one extra check.

Use a custom `RuleInterface` class when the check itself is new behavior; add `FunctionRuleInterface` / `AnonymousFunctionRuleInterface` when it must also cover named functions and closures.
Use a custom `RuleInterface` class when the check itself is new behavior; add `FunctionRuleInterface` / `AnonymousFunctionRuleInterface` / `AnonymousClassRuleInterface` when it must also cover named functions, closures, or anonymous classes.

Use a custom `PresetInterface` class when several layers and rules should be applied together or reused across repositories.
2 changes: 1 addition & 1 deletion docs/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ StructArmed ships with presets for common PHP standards and architecture styles.
| `Preset::PSR4()` | Verifies configured source paths exist in composer.json `autoload` or `autoload-dev` PSR-4 mappings |
| `Preset::PSR1()` | Basic Coding Standard checks: PHP tags, valid UTF-8, UTF-8 without BOM, symbols vs side effects, PSR-4 class placement, StudlyCaps class names, upper-case class constants, camelCase methods |
| `Preset::PSR12()` | Extends PSR-1: PHP keyword constants (`true`, `false`, and `null`) must be lowercase, and all methods, constants, and properties must declare explicit visibility |
| `Preset::PER()` | [PER Coding Style](https://www.php-fig.org/per/coding-style/): extends PSR-12 (and, through it, PSR-1) and adds PascalCase enum case names and no `protected` enum methods or constants |
| `Preset::PER()` | [PER Coding Style](https://www.php-fig.org/per/coding-style/): extends PSR-12 (and, through it, PSR-1) and adds PascalCase enum case names, no `protected` enum methods or constants, and no empty `()` on anonymous classes that pass no constructor argument (`new class {}`, not `new class () {}`); the anonymous-class and `protected` rules support `--fix` |
| `Preset::PSR15()` | `*Middleware` classes must implement PSR-15 `MiddlewareInterface`; `*Handler` classes must implement PSR-15 `RequestHandlerInterface`; StructArmed also enforces matching `Middleware`/`Handler` suffixes for implementations of those interfaces |
| `Preset::DDD()` | Layer isolation, entity/VO/repository/event/service conventions, including keeping Doctrine ORM repository inheritance out of the Domain layer |
| `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules, return types for helper functions |
Expand Down
27 changes: 18 additions & 9 deletions src/Analyser/Analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use Boundwize\StructArmed\File\SkipPathMatcher;
use Boundwize\StructArmed\LayerResolver\ChainLayerResolver;
use Boundwize\StructArmed\Progress\ProgressHandlerInterface;
use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface;
use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface;
use Boundwize\StructArmed\Rule\ComposerJsonRuleInterface;
use Boundwize\StructArmed\Rule\ExtendedClassAwareRuleInterface;
Expand Down Expand Up @@ -86,6 +87,7 @@ public function analyse(
$classNodeRules = [];
$functionNodeRules = [];
$anonymousFunctionNodeRules = [];
$anonymousClassNodeRules = [];
$layerAwareRules = [];
$hasExtendedClassAwareRule = false;
$hasUsedInterfaceAwareRule = false;
Expand Down Expand Up @@ -113,6 +115,11 @@ public function analyse(
$anonymousFunctionNodeRules[$key] = $rule;
}

if ($rule instanceof AnonymousClassRuleInterface) {
$nodeRules[$key] = $rule;
$anonymousClassNodeRules[$key] = $rule;
}

if ($rule instanceof LayerAwareRuleInterface) {
$layerAwareRules[] = $rule;
}
Expand Down Expand Up @@ -256,15 +263,16 @@ functionName: $violation->functionName,
$layerAwareRule->injectClassNodeMap($classDependencyMaps['classNodeMap']);
}

// Function-likes are not part of the class hierarchy, so they take no
// part in the declarative ruleset below; a rule only sees the node
// kind whose interface it implements, so each node collection is
// paired with the rules grouped for its kind above.
// Function-likes and anonymous classes are not part of the class
// hierarchy, so they take no part in the declarative ruleset below; a
// rule only sees the node kind whose interface it implements, so each
// node collection is paired with the rules grouped for its kind above.
$this->evaluateNodeRules(
[
[$classNodes, $classNodeRules],
[$extractionResult->functionNodes, $functionNodeRules],
[$extractionResult->anonymousFunctionNodes, $anonymousFunctionNodeRules],
[$extractionResult->anonymousClassNodes, $anonymousClassNodeRules],
],
$globalSkipPathMatcher,
$ruleSkipMatchers,
Expand Down Expand Up @@ -368,15 +376,16 @@ className: $classNode->className,

/**
* Evaluates each node collection against the rules grouped for its node
* kind, in a single evaluation implementation: all three rule interfaces
* kind, in a single evaluation implementation: all four rule interfaces
* share the appliesTo()/evaluate() method names, and a rule only receives
* the node kind whose interface it implements.
*
* @param list<array{0: list<ClassNode|FunctionNode|AnonymousFunctionNode>, 1: array<string, object>}> $nodeGroups
* @param list<array{0: list<object>, 1: array<string, object>}> $nodeGroups
* @param array<string, SkipPathMatcher> $ruleSkipMatchers
* @phpstan-param list<array{
* 0: list<ClassNode>|list<FunctionNode>|list<AnonymousFunctionNode>,
* 1: array<string, RuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface>
* 0: list<ClassNode>|list<FunctionNode>|list<AnonymousFunctionNode>|list<AnonymousClassNode>,
* 1: array<string, RuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface
* |AnonymousClassRuleInterface>
* }> $nodeGroups
*/
private function evaluateNodeRules(
Expand Down Expand Up @@ -548,7 +557,7 @@ private function isSourceSynthesised(Architecture $architecture): bool
}

/**
* @param array<string, RuleInterface|FunctionRuleInterface|AnonymousFunctionRuleInterface> $nodeRules
* @param array<string, object> $nodeRules Node rules of every kind, by key
* @param array<string, list<string>> $ruleSkipPaths
* @return array<string, SkipPathMatcher>
*/
Expand Down
46 changes: 32 additions & 14 deletions src/Analyser/AnalysisNodeCollector.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Boundwize\StructArmed\Analyser;

use Boundwize\StructArmed\LayerResolver\LayerResolverInterface;
use Boundwize\StructArmed\Util\PhpParser\AnonymousClassParentheses;
use Boundwize\StructArmed\Util\PhpParser\VisibilityFlagChecker;
use PhpParser\ConstExprEvaluationException;
use PhpParser\ConstExprEvaluator;
Expand Down Expand Up @@ -71,6 +72,7 @@
use PhpParser\Node\Stmt\Use_;
use PhpParser\Node\Stmt\While_;
use PhpParser\NodeVisitorAbstract;
use PhpParser\Token;

use function array_keys;
use function array_pop;
Expand Down Expand Up @@ -314,6 +316,9 @@ final class AnalysisNodeCollector extends NodeVisitorAbstract

private string $currentFile = '';

/** @var array<Token> */
private array $currentTokens = [];

/** @var array<string, true> */
private array $currentNamespaceUses = [];

Expand Down Expand Up @@ -392,9 +397,11 @@ public function __construct(
});
}

public function setCurrentFile(string $file): void
/** @param array<Token> $tokens The file's token stream, for the facts its AST does not carry */
public function setCurrentFile(string $file, array $tokens = []): void
{
$this->currentFile = $file;
$this->currentTokens = $tokens;
$this->currentFileReferences = [];
$this->currentFileInstantiations = [];
$this->nonCanonicalKeywordConstants = [];
Expand Down Expand Up @@ -690,19 +697,30 @@ public function leaveNode(Node $node): null
array_pop($this->activeClassLikeNames);
array_pop($this->functionLikeDepthAtClassLikeEntry);

if (! $node->name instanceof Identifier) {
// Anonymous classes never become ClassNodes, but the class they
// extend, the interfaces they implement, and the traits they use
// are still used within the scanned paths.
if ($node instanceof Class_) {
$this->anonymousClassNodes[] = new AnonymousClassNode(
file: $this->currentFile,
line: $node->getStartLine(),
extends: $node->extends instanceof Name ? $node->extends->toString() : null,
implements: $this->collectImplements($node),
traits: $this->collectTraits($node),
);
}
// Anonymous classes never become ClassNodes, but the class they
// extend, the interfaces they implement, and the traits they use
// are still used within the scanned paths.
if ($node instanceof Class_ && $node->isAnonymous()) {
// Its own (nameless) entry is already popped, so the innermost
// active names are the named scopes declaring it; they also
// resolve its layer, as they do for an anonymous function.
$enclosingClassName = $this->innermostActiveClassLikeName();
$enclosingFunctionName = $this->activeFunctionNames === [] ? null : end($this->activeFunctionNames);
[$layer, $layers] = $this->resolveLayerData($enclosingClassName ?? $enclosingFunctionName ?? '');

$this->anonymousClassNodes[] = new AnonymousClassNode(
file: $this->currentFile,
line: $node->getStartLine(),
extends: $node->extends instanceof Name ? $node->extends->toString() : null,
implements: $this->collectImplements($node),
traits: $this->collectTraits($node),
layer: $layer,
enclosingClassName: $enclosingClassName,
enclosingFunctionName: $enclosingFunctionName,
hasEmptyParentheses: AnonymousClassParentheses::emptyTokenRange($this->currentTokens, $node)
!== null,
layers: $layers,
);

return null;
}
Expand Down
2 changes: 1 addition & 1 deletion src/Analyser/AnalysisNodeExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public function extract(
$numericLiterals = [];

if ($ast !== null && $ast !== []) {
$analysisNodeCollector->setCurrentFile($file);
$analysisNodeCollector->setCurrentFile($file, $this->fileAnalysisProvider->tokens());
$nodeTraverser->traverse($ast);

$nonCanonicalKeywordConstants = $analysisNodeCollector->getNonCanonicalKeywordConstants();
Expand Down
54 changes: 47 additions & 7 deletions src/Analyser/AnonymousClassNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,67 @@

namespace Boundwize\StructArmed\Analyser;

use function array_filter;
use function in_array;

/**
* An anonymous class declaration (`new class ... {}`). Anonymous classes never
* become ClassNodes — they cannot be referenced by name and no rule targets
* them directly — but the class they extend, the interfaces they implement,
* and the traits they use are still used within the scanned paths, which
* usage-aware rules must take into account.
* become ClassNodes — they cannot be referenced by name — so one is identified
* by its file and line, plus the named class-like and/or function it is
* declared in, and rules target it through
* {@see \Boundwize\StructArmed\Rule\AnonymousClassRuleInterface}.
*
* The usage example is on MustBeFinalRule, which must skip if target class is extended by an anonymous class.
* 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.
*/
final readonly class AnonymousClassNode
{
/**
* @param string[] $implements Interface names this anonymous class implements
* @param string[] $traits Trait names this anonymous class uses
* 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;

/**
* @param string[] $implements Interface names this anonymous class implements
* @param string[] $traits Trait names this anonymous class uses
* @param string|null $enclosingClassName Innermost named class-like this anonymous class is declared in
* @param string|null $enclosingFunctionName Innermost named function this anonymous class is declared in
* @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]
*/
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,
array $layers = [],
) {
$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
* {@see self::FILE_SCOPE} for one declared in top-level procedural code.
*/
public function enclosingScopeName(): string
{
return $this->enclosingClassName ?? $this->enclosingFunctionName ?? self::FILE_SCOPE;
}
}
13 changes: 13 additions & 0 deletions src/Analyser/FileAnalysisProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
use PhpParser\Node\Stmt\Use_;
use PhpParser\Parser;
use PhpParser\ParserFactory;
use PhpParser\Token;

use function array_key_exists;
use function array_keys;
Expand Down Expand Up @@ -202,6 +203,18 @@ public function ast(string $file, bool $retainForAnalysis = true): ?array
return $this->parse($file);
}

/**
* The token stream of the file {@see ast()} parsed last, for the facts an
* AST does not carry. PHP-Parser keeps it until its next parse, so it is
* read right after ast(); the provider itself retains no token arrays.
*
* @return array<Token>
*/
public function tokens(): array
{
return $this->parser->getTokens();
}

/**
* Parses an already normalised file that has neither a cached AST nor an
* analysis, recording its AST, validity and invalid PHP tag line in one pass.
Expand Down
Loading