Skip to content
Draft
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
6 changes: 5 additions & 1 deletion docs/available-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,14 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`.
| `ClassNameMustBeStudlyCapsRule` | `new ClassNameMustBeStudlyCapsRule(layer: 'Source')` | Class names use StudlyCaps. |
| `ClassNameMustHaveSuffixRule` | `new ClassNameMustHaveSuffixRule(layer: 'Controller', suffix: 'Controller')` | Classes in a layer have the required suffix. |
| `ClassNameMustNotHavePrefixRule` | `new ClassNameMustNotHavePrefixRule(layer: 'Model', prefix: 'Model')` | Classes in a layer do not use a forbidden prefix. |
| `ExtendedClassMustBeAbstractOrInstantiatedRule` | `new ExtendedClassMustBeAbstractOrInstantiatedRule(layer: 'Source')` | Classes another scanned class extends are declared `abstract` unless they are also instantiated (`new X`, a `new self`/`new static`/`new parent` resolving to them, or a dynamic `new $class` whose class name resolves from a constant expression such as `X::class` or a class-name string). Type hints, `instanceof`, and `::class` keep working on an abstract class, so they do not count — but when the scanned code contains a dynamic instantiation whose target cannot be resolved (a factory's `new $class` fed by call sites, a `ReflectionClass::newInstance*()` call, `unserialize()`, or `eval()`), every class conservatively counts as instantiated and the rule stays silent — the target name may come from environment or configuration, so no class can be proven safe to abstract. Supports `--fix` by adding the `abstract` modifier. |
| `MaxDependencyCountRule` | `new MaxDependencyCountRule(layer: 'Controller', maxCount: 5)` | Constructor dependency count stays below the configured limit. |
| `MayNotImplementInterfaceRule` | `new MayNotImplementInterfaceRule(layer: 'Domain', interface: JsonSerializable::class)` | Classes in a layer do not implement a forbidden interface. |
| `MustBeFinalRule` | `new MustBeFinalRule(layer: 'Domain', classNamePattern: '/Entity$/')` | Matching classes in a layer are declared `final`. Classes extended by another scanned class are skipped (making them `final` would break the child). Supports `--fix`. |
| `MustBeUsedInterfaceRule` | `new MustBeUsedInterfaceRule(layer: 'Source')` | Interfaces are implemented by a scanned class (directly or through inheritance), extended by another scanned interface, or referenced as a dependency (type hint, `instanceof`, `::class`, a class-name string, ...). Supports `--fix` by removing the unused interface (and deleting its file when only boilerplate remains). |
| `MustBeInterfaceRule` | `new MustBeInterfaceRule(layer: 'Contract', classNamePattern: '/Interface$/')` | Matching declarations in a layer are interfaces. |
| `MustBeUsedAbstractClassRule` | `new MustBeUsedAbstractClassRule(layer: 'Source')` | Abstract classes are extended by a scanned class or referenced as a dependency (type hint, `instanceof`, `::class`, static call, a class-name string, ...). Supports `--fix` by removing the unused abstract class (and deleting its file when only boilerplate remains). |
| `MustBeUsedTraitRule` | `new MustBeUsedTraitRule(layer: 'Source')` | Traits are used by a scanned class, trait, or enum, or referenced as a dependency (`::class`, static call, a class-name string, ...). Supports `--fix` by removing the unused trait (and deleting its file when only boilerplate remains). |
| `MustDeclareConstantVisibilityRule` | `new MustDeclareConstantVisibilityRule(layer: 'Source')` | Class constants declare `public`, `protected`, or `private`. Supports `--fix`. |
| `MustDeclareMethodVisibilityRule` | `new MustDeclareMethodVisibilityRule(layer: 'Source')` | Methods declare `public`, `protected`, or `private`. Supports `--fix`. |
| `MustDeclarePropertyVisibilityRule` | `new MustDeclarePropertyVisibilityRule(layer: 'Source')` | Properties declare `public`, `protected`, or `private`. Supports `--fix`. |
Expand All @@ -91,7 +95,7 @@ Namespace: `Boundwize\StructArmed\Rule\Rules\Class_`.

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

`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `MustBeFinalRule`, `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, add the `final` class modifier, and add missing constant, method, or property visibility modifiers when you run `vendor/bin/structarmed analyse --fix`.
`Psr4DirectoryExistsRule`, `Psr1PhpTagsRule`, `Psr1Utf8WithoutBomRule`, `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, 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`.

## Layer Rules

Expand Down
3 changes: 3 additions & 0 deletions docs/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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::DDD()` | Layer isolation, entity/VO/repository/event/service conventions |
| `Preset::MVC()` | Layer isolation, thin controllers, model/view/service rules |
| `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, and extended classes that are never instantiated must be abstract — 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 dynamic `new $class` resolvable from a constant expression) keeps an extended class concrete. All rules support `--fix`, removing the unused declaration or adding the `abstract` modifier |

## Initialize Presets

Expand All @@ -35,6 +36,7 @@ vendor/bin/structarmed init --preset=psr12
vendor/bin/structarmed init --preset=psr15
vendor/bin/structarmed init --preset=mvc
vendor/bin/structarmed init --preset=ddd
vendor/bin/structarmed init --preset=yagni
vendor/bin/structarmed init --preset=all
```

Expand All @@ -49,6 +51,7 @@ return Architecture::define()
Preset::PSR15(),
Preset::MVC(),
Preset::DDD(),
Preset::YAGNI(),
);
```

Expand Down
221 changes: 219 additions & 2 deletions src/Analyser/Analyser.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@
use Boundwize\StructArmed\Rule\RuleInterface;
use Boundwize\StructArmed\Rule\RuleViolation;
use Boundwize\StructArmed\Rule\RuleViolationCollection;
use Boundwize\StructArmed\Rule\UsedInterfaceAwareRuleInterface;
use Boundwize\StructArmed\Rule\UsedTraitAwareRuleInterface;
use Boundwize\StructArmed\Util\Path;

use function array_fill_keys;
Expand Down Expand Up @@ -83,6 +85,8 @@ public function analyse(
$classRules = [];
$layerAwareRules = [];
$hasExtendedClassAwareRule = false;
$hasUsedInterfaceAwareRule = false;
$hasUsedTraitAwareRule = false;

foreach ($rules as $key => $rule) {
if (array_key_exists($key, $skippedRuleKeys)) {
Expand All @@ -101,6 +105,14 @@ public function analyse(
$hasExtendedClassAwareRule = true;
}

if ($rule instanceof UsedInterfaceAwareRuleInterface) {
$hasUsedInterfaceAwareRule = true;
}

if ($rule instanceof UsedTraitAwareRuleInterface) {
$hasUsedTraitAwareRule = true;
}

if (! $rule instanceof ProjectRuleInterface) {
continue;
}
Expand Down Expand Up @@ -152,6 +164,18 @@ public function analyse(
$this->markExtendedClasses($classNodes, $extractionResult);
}

if ($hasUsedInterfaceAwareRule) {
$this->markImplementedInterfaces($classNodes, $extractionResult);
}

if ($hasExtendedClassAwareRule || $hasUsedInterfaceAwareRule || $hasUsedTraitAwareRule) {
$this->markReferencedClassLikes($classNodes, $extractionResult);
}

if ($hasExtendedClassAwareRule) {
$this->markInstantiatedClasses($classNodes, $extractionResult);
}

if ($withFileAnalysis) {
$fileAnalysisProvider = new FileAnalysisProvider(
analyses: $extractionResult->fileAnalyses,
Expand Down Expand Up @@ -688,6 +712,159 @@ private function markExtendedClasses(array $classNodes, ExtractionResult $extrac
}
}

/**
* Flag every interface that a scanned class implements (directly or through
* inheritance) or another scanned interface extends, using the recursive
* parent chain resolved by {@see withRecursiveParents()}.
*
* @param list<ClassNode> $classNodes
*/
private function markImplementedInterfaces(array $classNodes, ExtractionResult $extractionResult): void
{
$implemented = [];

foreach ($classNodes as $classNode) {
foreach ($classNode->parentInterfaces as $parentInterface) {
$implemented[strtolower($parentInterface)] = true;
}
}

// Anonymous classes (`new class implements Foo {}`) have no ClassNode
// of their own, so the interfaces they implement are tracked separately.
foreach ($extractionResult->anonymousClassNodes as $anonymousClassNode) {
foreach ($anonymousClassNode->implements as $interface) {
$implemented[strtolower($interface)] = true;
}
}

foreach ($classNodes as $classNode) {
// Only interfaces appear in parentInterfaces; classes, traits, and
// enums never do, so they are left with the default (not implemented).
if (isset($implemented[strtolower($classNode->className)])) {
$classNode->setImplemented(true);
}
}
}

/**
* Flag every class-like that another scanned class-like (class, trait, or
* enum) uses — as a trait, or by referencing it as a dependency: a type
* hint, an instanceof check, a ::class constant, a static call, and so on.
* Self-references are ignored: a class-like cannot keep itself alive.
* Usage is a direct declaration, so no recursive chain is needed: a
* class-like used only by another unused one stays flagged as used until
* its user is removed, at which point the next run reports it.
*
* @param list<ClassNode> $classNodes
*/
private function markReferencedClassLikes(array $classNodes, ExtractionResult $extractionResult): void
{
$used = [];

foreach ($classNodes as $classNode) {
foreach ($classNode->traits as $trait) {
$used[strtolower($trait)] = true;
}

// A node's own inheritance-clause names (and the imports that
// exist for them) are structural relations, not value references.
// Excluding them keeps "referenced" meaningful for the unresolved
// dynamic instantiation check below: a class extended by a child
// is not thereby a possible `new $class` target. The usage-aware
// deletion rules are unaffected — each combines this flag with its
// structural extended/implemented/trait marking.
$excludedKeys = [strtolower($classNode->className) => true];

if ($classNode->extends !== null) {
$excludedKeys[strtolower($classNode->extends)] = true;
}

foreach ([$classNode->implements, $classNode->interfaceExtends, $classNode->traits] as $clauseNames) {
foreach ($clauseNames as $clauseName) {
$excludedKeys[strtolower($clauseName)] = true;
}
}

foreach ($classNode->dependencies as $dependency) {
$dependencyKey = strtolower($dependency);

if (! isset($excludedKeys[$dependencyKey])) {
$used[$dependencyKey] = true;
}
}
}

// Anonymous classes (`new class { use Foo; }`) have no ClassNode of
// their own, so the traits they use are tracked separately.
foreach ($extractionResult->anonymousClassNodes as $anonymousClassNode) {
foreach ($anonymousClassNode->traits as $trait) {
$used[strtolower($trait)] = true;
}
}

// References made outside any named class-like scope — procedural
// functions, top-level statements, top-level anonymous class bodies —
// have no ClassNode either, so they are tracked per file.
foreach ($extractionResult->fileReferences as $references) {
foreach ($references as $reference) {
$used[strtolower($reference)] = true;
}
}

// An instantiation is also a reference; the sentinel is not a class
// name and marks nothing here.
foreach ($extractionResult->fileInstantiations as $instantiations) {
foreach ($instantiations as $instantiation) {
if ($instantiation !== ClassCollector::UNRESOLVED_INSTANTIATION) {
$used[strtolower($instantiation)] = true;
}
}
}

foreach ($classNodes as $classNode) {
if (isset($used[strtolower($classNode->className)])) {
$classNode->setReferenced(true);
}
}
}

/**
* Flag every concrete class that another scanned scope instantiates —
* `new X` (with self/static/parent already resolved), a dynamic
* `new $class` resolved from constant class-name values, and so on.
* No self-exclusion here: a class instantiating itself cannot become
* abstract either.
*
* A dynamic instantiation whose target could not be resolved statically —
* `new $class` on a function parameter, a ReflectionClass construction,
* unserialize(), eval() — may target any class: the name can come from
* the environment, configuration, or a payload, entirely outside the
* scanned code. No class can then be proven safe to abstract, so every
* class-like conservatively counts as instantiated. Resolvable
* construction keeps precise detection; unresolvable construction
* silences the concreteness fix.
*
* @param list<ClassNode> $classNodes
*/
private function markInstantiatedClasses(array $classNodes, ExtractionResult $extractionResult): void
{
$instantiated = [];

foreach ($extractionResult->fileInstantiations as $instantiations) {
foreach ($instantiations as $instantiation) {
$instantiated[strtolower($instantiation)] = true;
}
}

$hasUnresolvedInstantiation = isset($instantiated[ClassCollector::UNRESOLVED_INSTANTIATION]);

foreach ($classNodes as $classNode) {
if ($hasUnresolvedInstantiation || isset($instantiated[strtolower($classNode->className)])) {
$classNode->setInstantiated(true);
}
}
}

/**
* @param list<ClassNode> $classNodes
* @return list<ClassNode>
Expand Down Expand Up @@ -852,6 +1029,8 @@ private function collectClassNodes(
$classNodes = [];
$fileAnalyses = [];
$anonymousClassNodes = [];
$fileReferences = [];
$fileInstantiations = [];
$filesToParse = [];

foreach ($files as $file) {
Expand All @@ -874,6 +1053,14 @@ private function collectClassNodes(
$anonymousClassNodes[] = $cachedAnonymousClassNode;
}

if ($cachedResult['fileReferences'] !== []) {
$fileReferences[$file] = $cachedResult['fileReferences'];
}

if ($cachedResult['fileInstantiations'] !== []) {
$fileInstantiations[$file] = $cachedResult['fileInstantiations'];
}

$fileAnalyses[$file] = $cachedResult['fileAnalysis'];

continue;
Expand All @@ -896,14 +1083,28 @@ private function collectClassNodes(
foreach ($cachedResult['anonymousClassNodes'] as $cachedAnonymousClassNode) {
$anonymousClassNodes[] = $cachedAnonymousClassNode;
}

if ($cachedResult['fileReferences'] !== []) {
$fileReferences[$file] = $cachedResult['fileReferences'];
}

if ($cachedResult['fileInstantiations'] !== []) {
$fileInstantiations[$file] = $cachedResult['fileInstantiations'];
}
}

$progressHandler?->start(count($filesToParse));

if ($filesToParse === []) {
$progressHandler?->finish();

return new ExtractionResult($classNodes, $fileAnalyses, $anonymousClassNodes);
return new ExtractionResult(
$classNodes,
$fileAnalyses,
$anonymousClassNodes,
$fileReferences,
$fileInstantiations,
);
}

$options = $analyserOptions ?? AnalyserOptions::parallel();
Expand Down Expand Up @@ -946,19 +1147,35 @@ private function collectClassNodes(
$fileAnalyses[$file] = $fileAnalysis;
}

foreach ($parsedResult->fileReferences as $file => $parsedFileReferences) {
$fileReferences[$file] = $parsedFileReferences;
}

foreach ($parsedResult->fileInstantiations as $file => $parsedFileInstantiations) {
$fileInstantiations[$file] = $parsedFileInstantiations;
}

foreach ($classNodesByFile as $fileToParse => $fileClassNodes) {
$this->analysisResultCache?->storeClassNodes(
$fileToParse,
$this->classNodeCacheNamespace,
$fileClassNodes,
$fileAnalyses[$fileToParse] ?? null,
$anonymousClassNodesByFile[$fileToParse] ?? [],
$fileReferences[$fileToParse] ?? [],
$fileInstantiations[$fileToParse] ?? [],
);
}

$progressHandler?->finish();

return new ExtractionResult($classNodes, $fileAnalyses, $anonymousClassNodes);
return new ExtractionResult(
$classNodes,
$fileAnalyses,
$anonymousClassNodes,
$fileReferences,
$fileInstantiations,
);
}

/**
Expand Down
Loading
Loading