diff --git a/docs/available-rules.md b/docs/available-rules.md index 3612cd65..8ab531ee 100644 --- a/docs/available-rules.md +++ b/docs/available-rules.md @@ -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 diff --git a/docs/custom-rules-and-presets.md b/docs/custom-rules-and-presets.md index 8bc06117..824b9883 100644 --- a/docs/custom-rules-and-presets.md +++ b/docs/custom-rules-and-presets.md @@ -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. @@ -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 +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. diff --git a/src/Analyser/Analyser.php b/src/Analyser/Analyser.php index c522f462..2bbaa340 100644 --- a/src/Analyser/Analyser.php +++ b/src/Analyser/Analyser.php @@ -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( @@ -1051,10 +1051,11 @@ private function collectTraitUsers( } /** - * @param list $classNodes + * @param list $classNodes + * @param list $anonymousClassNodes * @return list */ - private function withRecursiveParents(array $classNodes): array + private function withRecursiveParents(array $classNodes, array $anonymousClassNodes): array { $parentClassMap = []; $parentInterfaceMap = []; @@ -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> $parentClassMap * @param array> $parentInterfaceMap @@ -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> $parentClassMap + * @param array> $parentInterfaceMap + * @param array, interfaces: list}> $cache + * @param array $seen + * @return array{classes: list, interfaces: list} + */ + 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])) { @@ -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])) { @@ -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; } /** diff --git a/src/Analyser/AnonymousClassNode.php b/src/Analyser/AnonymousClassNode.php index 3e2728ac..d5a390c6 100644 --- a/src/Analyser/AnonymousClassNode.php +++ b/src/Analyser/AnonymousClassNode.php @@ -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 @@ -17,9 +16,16 @@ * 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. @@ -27,7 +33,7 @@ public const FILE_SCOPE = 'file scope'; /** @var list */ - public array $layers; + public readonly array $layers; /** * @param string[] $implements Interface names this anonymous class implements @@ -37,27 +43,26 @@ * @param bool $hasEmptyParentheses Whether `()` follows `class` although no constructor argument * is passed: `new class () {}` rather than `new class {}` * @param list $layers Layer names this anonymous class belongs to; defaults to [$layer] + * @param list $parentClasses Direct and transitive parent class names + * @param list $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 diff --git a/src/Analyser/ClassNode.php b/src/Analyser/ClassNode.php index f8aee928..0e504276 100644 --- a/src/Analyser/ClassNode.php +++ b/src/Analyser/ClassNode.php @@ -8,13 +8,13 @@ use function preg_match; use function str_ends_with; use function str_starts_with; -use function strcasecmp; use function strrpos; use function substr; final class ClassNode { use NodeQueryTrait; + use RecursiveParentsTrait; /** @var list */ public readonly array $layers; @@ -93,16 +93,6 @@ public function getType(): string return 'Class'; } - /** - * @param list $parentClasses - * @param list $parentInterfaces - */ - public function setRecursiveParents(array $parentClasses, array $parentInterfaces): void - { - $this->parentClasses = $parentClasses; - $this->parentInterfaces = $parentInterfaces; - } - /** * Whether another scanned class extends this class. Computed by the analyser * for rules implementing ExtendedClassAwareRuleInterface; false otherwise. @@ -176,6 +166,7 @@ public function nameMatches(string $pattern, bool $isFullName = false): bool /** * Implemented directly, extended directly (for interfaces), or via any parent class or interface. + * Overrides the trait method to also look at `$interfaceExtends`, which only a named interface has. */ public function implementsInterface(string $interface): bool { @@ -184,32 +175,6 @@ public function implementsInterface(string $interface): bool || $this->matchesAnyClassLike($interface, $this->parentInterfaces); } - public function extendsClass(string $class): bool - { - if ($this->extends !== null && strcasecmp($this->extends, $class) === 0) { - return true; - } - - return $this->matchesAnyClassLike($class, $this->parentClasses); - } - - /** - * Class-like names are case-insensitive in PHP. This matching is kept - * separate from dependencies, which may also contain constants. - * - * @param string[] $classLikes - */ - private function matchesAnyClassLike(string $needle, array $classLikes): bool - { - foreach ($classLikes as $classLike) { - if (strcasecmp($classLike, $needle) === 0) { - return true; - } - } - - return false; - } - public function constructorParamCount(): int { foreach ($this->methods as $method) { diff --git a/src/Analyser/LayerQueryTrait.php b/src/Analyser/LayerQueryTrait.php new file mode 100644 index 00000000..e36bad37 --- /dev/null +++ b/src/Analyser/LayerQueryTrait.php @@ -0,0 +1,24 @@ + $layers All layer names this node belongs to; assigned once in each node's constructor + */ +trait LayerQueryTrait +{ + public function isInLayer(string $layer): bool + { + return in_array($layer, $this->layers, true); + } +} diff --git a/src/Analyser/NodeQueryTrait.php b/src/Analyser/NodeQueryTrait.php index 4c58608a..2e0c7d04 100644 --- a/src/Analyser/NodeQueryTrait.php +++ b/src/Analyser/NodeQueryTrait.php @@ -18,7 +18,6 @@ * * @internal * - * @property list $layers All layer names this node belongs to; assigned once in each node's constructor * @property-read list $dependencies Fully-qualified class, function, or constant dependencies * @property-read string[] $functionCalls Functions called within this node * @property-read string[] $superglobals Superglobals accessed ($_GET, $_POST, etc.) @@ -26,10 +25,7 @@ */ trait NodeQueryTrait { - public function isInLayer(string $layer): bool - { - return in_array($layer, $this->layers, true); - } + use LayerQueryTrait; public function dependsOn(string $class): bool { diff --git a/src/Analyser/RecursiveParentsTrait.php b/src/Analyser/RecursiveParentsTrait.php new file mode 100644 index 00000000..4ddab395 --- /dev/null +++ b/src/Analyser/RecursiveParentsTrait.php @@ -0,0 +1,68 @@ + $parentClasses Direct and transitive parent class names + * @property list $parentInterfaces Direct and transitive implemented or extended interface names + */ +trait RecursiveParentsTrait +{ + /** + * @param list $parentClasses + * @param list $parentInterfaces + */ + public function setRecursiveParents(array $parentClasses, array $parentInterfaces): void + { + $this->parentClasses = $parentClasses; + $this->parentInterfaces = $parentInterfaces; + } + + /** + * Implemented directly, or via any parent class or interface. + */ + public function implementsInterface(string $interface): bool + { + return $this->matchesAnyClassLike($interface, $this->implements) + || $this->matchesAnyClassLike($interface, $this->parentInterfaces); + } + + public function extendsClass(string $class): bool + { + if ($this->extends !== null && strcasecmp($this->extends, $class) === 0) { + return true; + } + + return $this->matchesAnyClassLike($class, $this->parentClasses); + } + + /** + * Class-like names are case-insensitive in PHP. This matching is kept + * separate from dependencies, which may also contain constants. + * + * @param string[] $classLikes + */ + private function matchesAnyClassLike(string $needle, array $classLikes): bool + { + foreach ($classLikes as $classLike) { + if (strcasecmp($classLike, $needle) === 0) { + return true; + } + } + + return false; + } +} diff --git a/tests/Analyser/AnalyserTest.php b/tests/Analyser/AnalyserTest.php index ed05d360..1c7f6d89 100644 --- a/tests/Analyser/AnalyserTest.php +++ b/tests/Analyser/AnalyserTest.php @@ -6,6 +6,7 @@ use Boundwize\StructArmed\Analyser\Analyser; use Boundwize\StructArmed\Analyser\AnalyserOptions; +use Boundwize\StructArmed\Analyser\AnonymousClassNode; use Boundwize\StructArmed\Analyser\AnonymousFunctionNode; use Boundwize\StructArmed\Analyser\FileAnalysisProvider; use Boundwize\StructArmed\Analyser\FunctionNode; @@ -24,6 +25,7 @@ use Boundwize\StructArmed\Preset\Presets\Psr4Preset; use Boundwize\StructArmed\Preset\Presets\YagniPreset; use Boundwize\StructArmed\Progress\ProgressHandlerInterface; +use Boundwize\StructArmed\Rule\AnonymousClassRuleInterface; use Boundwize\StructArmed\Rule\AnonymousFunctionRuleInterface; use Boundwize\StructArmed\Rule\FileAnalysisRuleInterface; use Boundwize\StructArmed\Rule\FunctionRuleInterface; @@ -55,6 +57,7 @@ use function realpath; use function rename; use function sort; +use function sprintf; use function str_replace; use function symlink; use function unlink; @@ -353,6 +356,71 @@ public function testAnonymousClassRulesAreEvaluatedAgainstAnonymousClasses(): vo } } + public function testAnonymousClassNodesResolveRecursiveParents(): void + { + $basePath = $this->makeTempProject([ + 'src/Contract.php' => ' ' ' <<<'PHP' + extendsClass('App\\RootHandler') + && $anonymousClassNode->implementsInterface('App\\Contract') + ) { + return null; + } + + return new RuleViolation( + message: sprintf( + 'Anonymous class in [%s] must implement [App\\Contract]', + $anonymousClassNode->enclosingScopeName() + ), + file: $anonymousClassNode->file, + line: $anonymousClassNode->line, + className: $anonymousClassNode->enclosingScopeName(), + layer: $anonymousClassNode->layer, + ); + } + }; + + $architecture = Architecture::define() + ->layer('Source', 'src/') + ->rule('anonymous_classes.contract', $rule); + + foreach ([AnalyserOptions::sequential(), AnalyserOptions::parallel(2)] as $analyserOptions) { + $violations = (new Analyser($basePath)) + ->analyse($architecture, [], null, $analyserOptions) + ->forRule('anonymous_classes.contract'); + + // Only the anonymous class with no parent chain is flagged: the one + // extending BaseHandler reaches RootHandler and Contract transitively. + $this->assertCount(1, $violations); + $this->assertSame(5, $violations[0]->line); + $this->assertSame( + 'Anonymous class in [App\\Factory] must implement [App\\Contract]', + $violations[0]->message + ); + } + } + public function testAnonymousClassRuleViolationsSurviveTheAnalysisNodeCache(): void { $basePath = $this->makeTempProject($this->anonymousClassRuleProjectFiles()); diff --git a/tests/Analyser/AnonymousClassNodeTest.php b/tests/Analyser/AnonymousClassNodeTest.php new file mode 100644 index 00000000..b6456c22 --- /dev/null +++ b/tests/Analyser/AnonymousClassNodeTest.php @@ -0,0 +1,49 @@ +assertSame([], $anonymousClassNode->parentClasses); + $this->assertSame([], $anonymousClassNode->parentInterfaces); + $this->assertTrue($anonymousClassNode->extendsClass('App\\Support\\BaseClass')); + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\RootClass')); + $this->assertTrue($anonymousClassNode->implementsInterface('App\\Contracts\\FooInterface')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\RootInterface')); + + $anonymousClassNode->setRecursiveParents( + ['App\\Support\\baseclass', 'App\\Support\\rootclass'], + ['App\\Contracts\\foointerface', 'App\\Contracts\\rootinterface'], + ); + + $this->assertSame(['App\\Support\\baseclass', 'App\\Support\\rootclass'], $anonymousClassNode->parentClasses); + $this->assertTrue($anonymousClassNode->extendsClass('App\\Support\\RootClass')); + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\OtherClass')); + $this->assertTrue($anonymousClassNode->implementsInterface('App\\Contracts\\RootInterface')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\OtherInterface')); + } + + public function testExtendsClassWithoutParentIsAlwaysFalse(): void + { + $anonymousClassNode = new AnonymousClassNode(file: '/src/helpers.php', line: 3, extends: null); + + $this->assertFalse($anonymousClassNode->extendsClass('App\\Support\\BaseClass')); + $this->assertFalse($anonymousClassNode->implementsInterface('App\\Contracts\\FooInterface')); + } +}