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
186 changes: 186 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1420,6 +1420,97 @@ final class SomeService

<br>

### NoPropertyToPropertyAssignRule

An object property must not be assigned from another object property of the same object - it keeps the same service under 2 names. Use the original property directly instead.

```yaml
rules:
- Symplify\PHPStanRules\Rules\Complexity\NoPropertyToPropertyAssignRule
```

```php
$this->repository = $this->someRepository;
```

:x:

<br>

```php
// use $this->someRepository directly
```

:+1:

<br>

### NoDuplicateNonRepeatableAttributeRule

An attribute can only be repeated on the same class, method or property when it is declared with the `\Attribute::IS_REPEATABLE` flag.

```yaml
rules:
- Symplify\PHPStanRules\Rules\NoDuplicateNonRepeatableAttributeRule
```

```php
#[SomeAttribute]
#[SomeAttribute]
private string $name;
```

:x:

<br>

```php
#[SomeAttribute]
private string $name;
```

:+1:

<br>

### RequireArrayShapeReturnRule

A method that returns a packed keyed array of 2-3 named values should declare that shape in its `@return`, so the caller knows each key and its type.

```yaml
rules:
- Symplify\PHPStanRules\Rules\RequireArrayShapeReturnRule
```

```php
public function run(): array
{
return ['name' => $name, 'age' => $age];
}
```

:x:

<br>

```php
/**
* @return array{name: string, age: int}
*/
public function run(): array
{
return ['name' => $name, 'age' => $age];
}
```

:+1:

<br>

---

<br>

## 2. Doctrine-specific Rules

### RequireQueryBuilderOnRepositoryRule
Expand Down Expand Up @@ -2615,6 +2706,101 @@ return function (ContainerConfigurator $container) {

<br>

### CommandMustHaveAsCommandAttributeRule

Every class that extends Symfony `Command` must declare the `#[AsCommand]` attribute.

```yaml
rules:
- Symplify\PHPStanRules\Rules\Symfony\CommandMustHaveAsCommandAttributeRule
```

```php
final class ReportCommand extends Command
{
}
```

:x:

<br>

```php
#[AsCommand('app:report')]
final class ReportCommand extends Command
{
}
```

:+1:

<br>

### ConstraintMustHaveAttributeRule

Every class that extends Symfony `Constraint` must declare the `#[\Attribute]` attribute, so it can be used as an attribute on properties.

```yaml
rules:
- Symplify\PHPStanRules\Rules\Symfony\ConstraintMustHaveAttributeRule
```

```php
final class UniqueEmail extends Constraint
{
}
```

:x:

<br>

```php
#[\Attribute]
final class UniqueEmail extends Constraint
{
}
```

:+1:

<br>

### PreferInterfaceInConstructorRule

A constructor dependency typed as a concrete Symfony/Doctrine class that has a same-named `*Interface` should use that interface instead - it blocks decoration otherwise.

```yaml
rules:
- Symplify\PHPStanRules\Rules\Symfony\PreferInterfaceInConstructorRule
```

```php
public function __construct(
private Router $router,
) {
}
```

:x:

<br>

```php
public function __construct(
private RouterInterface $router,
) {
}
```

:+1:

<br>

---

<br>

## 4. PHPUnit-specific Rules

### NoAssertFuncCallInTestsRule
Expand Down
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@
"illuminate/container": "^11.51",
"symplify/easy-coding-standard": "^13.3",
"tomasvotruba/class-leak": "^2.2",
"rector/rector": "^2.6",
"rector/rector": "^2.6.7",
"phpstan/extension-installer": "^1.4",
"tomasvotruba/unused-public": "^2.2",
"tomasvotruba/type-coverage": "^2.3",
"shipmonk/composer-dependency-analyser": "^1.8",
"rector/jack": "^1.0",
"rector/jack": "^1.1",
"nette/neon": "^3.4"
},
"autoload": {
Expand Down
1 change: 1 addition & 0 deletions config/code-complexity-rules.neon
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
rules:
- Symplify\PHPStanRules\Rules\NoDynamicNameRule
- Symplify\PHPStanRules\Rules\Complexity\NoJustPropertyAssignRule
- Symplify\PHPStanRules\Rules\Complexity\NoPropertyToPropertyAssignRule
- Symplify\PHPStanRules\Rules\Complexity\NoArrayMapWithArrayCallableRule
- Symplify\PHPStanRules\Rules\Complexity\NoConstructorOverrideRule
- Symplify\PHPStanRules\Rules\Complexity\ForeachCeptionRule
Expand Down
4 changes: 4 additions & 0 deletions config/static-rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ rules:

# docblock
- Symplify\PHPStanRules\Rules\NoMissnamedDocTagRule
- Symplify\PHPStanRules\Rules\RequireArrayShapeReturnRule

# attributes
- Symplify\PHPStanRules\Rules\NoDuplicateNonRepeatableAttributeRule
5 changes: 5 additions & 0 deletions config/symfony-rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,8 @@ rules:
# attributes
- Symplify\PHPStanRules\Rules\Symfony\RequireIsGrantedEnumRule
- Symplify\PHPStanRules\Rules\Symfony\NoBareAndSecurityIsGrantedContentsRule
- Symplify\PHPStanRules\Rules\Symfony\CommandMustHaveAsCommandAttributeRule
- Symplify\PHPStanRules\Rules\Symfony\ConstraintMustHaveAttributeRule

# constructor injection
- Symplify\PHPStanRules\Rules\Symfony\PreferInterfaceInConstructorRule
2 changes: 2 additions & 0 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
__DIR__ . '/src/Enum',
__DIR__ . '/src/Testing/PHPUnitTestAnalyser.php',
__DIR__ . '/src/Rules/NoEntityOutsideEntityNamespaceRule.php',
__DIR__ . '/src/Rules/Symfony/CommandMustHaveAsCommandAttributeRule.php',
__DIR__ . '/src/Rules/Symfony/ConstraintMustHaveAttributeRule.php',
__DIR__ . '/tests/Naming/ClassToSuffixResolverTest.php',
__DIR__ . '/src/Doctrine/DoctrineEntityDocumentAnalyser.php',
],
Expand Down
12 changes: 12 additions & 0 deletions src/Enum/RuleIdentifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,16 @@ final class RuleIdentifier
public const string NO_MISSNAMED_DOC_TAG = 'symplify.noMissnamedDocTag';

public const string NEW_OVER_SETTERS = 'symplify.newOverSetters';

public const string COMMAND_HAS_AS_COMMAND_ATTRIBUTE = 'symplify.commandHasAsCommandAttribute';

public const string CONSTRAINT_HAS_ATTRIBUTE = 'symplify.constraintHasAttribute';

public const string PREFER_INTERFACE_IN_CONSTRUCTOR = 'symplify.preferInterfaceInConstructor';

public const string NO_DUPLICATE_NON_REPEATABLE_ATTRIBUTE = 'symplify.noDuplicateNonRepeatableAttribute';

public const string NO_PROPERTY_TO_PROPERTY_ASSIGN = 'symplify.noPropertyToPropertyAssign';

public const string REQUIRE_ARRAY_SHAPE_RETURN = 'symplify.requireArrayShapeReturn';
}
103 changes: 103 additions & 0 deletions src/Rules/Complexity/NoPropertyToPropertyAssignRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

declare(strict_types=1);

namespace Symplify\PHPStanRules\Rules\Complexity;

use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\Assign;
use PhpParser\Node\Expr\PropertyFetch;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Identifier;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use Symplify\PHPStanRules\Enum\RuleIdentifier;

/**
* An object property must not be assigned from another object property of the same object.
*
* "$this->repository = $this->someRepository;" keeps the very same service under 2 names, so both properties have to be
* kept in sync forever. Use the original property directly instead and drop the duplicate one.
*
* Only object properties are reported - a scalar or array property is often a deliberate snapshot of a previous state,
* e.g. "$this->bodyInitial = $this->body;". Anonymous classes are skipped, as they are local one-offs.
*
* @see \Symplify\PHPStanRules\Tests\Rules\Complexity\NoPropertyToPropertyAssignRule\NoPropertyToPropertyAssignRuleTest
*
* @implements Rule<Assign>
*/
final class NoPropertyToPropertyAssignRule implements Rule
{
public const string ERROR_MESSAGE = 'Property "$this->%s" must not be assigned from property "$this->%s". Use the original property directly instead';

public function getNodeType(): string
{
return Assign::class;
}

/**
* @param Assign $node
*
* @return list<IdentifierRuleError>
*/
public function processNode(Node $node, Scope $scope): array
{
// an anonymous class is a local one-off, e.g. a test double filling a parent property
$classReflection = $scope->getClassReflection();
if (! $classReflection instanceof ClassReflection || $classReflection->isAnonymous()) {
return [];
}

$assignedPropertyName = $this->matchThisPropertyName($node->var);
if ($assignedPropertyName === null) {
return [];
}

$sourcePropertyName = $this->matchThisPropertyName($node->expr);
if ($sourcePropertyName === null) {
return [];
}

// "$this->items = $this->items" is a different smell, not a duplicated property
if ($assignedPropertyName === $sourcePropertyName) {
return [];
}

// a scalar or array property is often a deliberate snapshot of a previous state
if ($scope->getType($node->expr)->getObjectClassNames() === []) {
return [];
}

$identifierRuleError = RuleErrorBuilder::message(
sprintf(self::ERROR_MESSAGE, $assignedPropertyName, $sourcePropertyName)
)
->identifier(RuleIdentifier::NO_PROPERTY_TO_PROPERTY_ASSIGN)
->build();

return [$identifierRuleError];
}

/**
* Returns the property name of a "$this->someProperty" fetch, null for anything else.
*/
private function matchThisPropertyName(Expr $expr): ?string
{
if (! $expr instanceof PropertyFetch) {
return null;
}

if (! $expr->var instanceof Variable || $expr->var->name !== 'this') {
return null;
}

if (! $expr->name instanceof Identifier) {
return null;
}

return $expr->name->toString();
}
}
Loading
Loading