Skip to content

Commit 7d161e5

Browse files
authored
ForbidVariableTypeOverwritingRule (#34)
* ForbidVariableTypeOverwritingRule * Better test, dont generalize array key+value * readme array append note
1 parent bfdf0d3 commit 7d161e5

File tree

4 files changed

+288
-0
lines changed

4 files changed

+288
-0
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,21 @@ function getFullName(?string $firstName, string $lastName): string {
224224
}
225225
```
226226

227+
### ForbidVariableTypeOverwritingRule
228+
- Restricts variable assignment to those that does not change its type
229+
- Array append `$array[] = 1;` not yet supported
230+
- Null and mixed are not taken into account, advanced phpstan types like non-empty-X are trimmed before comparison
231+
- Rule allows type generalization and type narrowing (parent <-> child)
232+
```neon
233+
rules:
234+
- ShipMonk\PHPStan\Rule\ForbidVariableTypeOverwritingRule
235+
```
236+
```php
237+
function example(OrderId $id) {
238+
$id = $id->getStringValue(); // denied, type changed from object to string
239+
}
240+
```
241+
227242
### ForbidUnsetClassFieldRule
228243
- Denies calling `unset` over class field as it causes un-initialization, see https://3v4l.org/V8uuP
229244
- Null assignment should be used instead
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\PHPStan\Rule;
4+
5+
use PhpParser\Node;
6+
use PhpParser\Node\Expr;
7+
use PhpParser\Node\Expr\Assign;
8+
use PhpParser\Node\Expr\Variable;
9+
use PHPStan\Analyser\Scope;
10+
use PHPStan\Rules\Rule;
11+
use PHPStan\Type\Accessory\AccessoryType;
12+
use PHPStan\Type\ConstantType;
13+
use PHPStan\Type\Enum\EnumCaseObjectType;
14+
use PHPStan\Type\GeneralizePrecision;
15+
use PHPStan\Type\IntegerRangeType;
16+
use PHPStan\Type\IntersectionType;
17+
use PHPStan\Type\MixedType;
18+
use PHPStan\Type\NullType;
19+
use PHPStan\Type\ObjectType;
20+
use PHPStan\Type\Type;
21+
use PHPStan\Type\TypeCombinator;
22+
use PHPStan\Type\UnionType;
23+
use PHPStan\Type\VerbosityLevel;
24+
25+
/**
26+
* @implements Rule<Assign>
27+
*/
28+
class ForbidVariableTypeOverwritingRule implements Rule
29+
{
30+
31+
public function getNodeType(): string
32+
{
33+
return Assign::class;
34+
}
35+
36+
/**
37+
* @param Assign $node
38+
* @return string[]
39+
*/
40+
public function processNode(Node $node, Scope $scope): array
41+
{
42+
if (!$node->var instanceof Variable) {
43+
return []; // array append not yet supported
44+
}
45+
46+
$variableName = $node->var->name;
47+
48+
if ($variableName instanceof Expr) {
49+
return []; // no support for cases like $$foo
50+
}
51+
52+
if (!$scope->hasVariableType($variableName)->yes()) {
53+
return [];
54+
}
55+
56+
$previousVariableType = $this->generalize($scope->getVariableType($variableName));
57+
$newVariableType = $this->generalize($scope->getType($node->expr));
58+
59+
if ($this->isTypeToIgnore($previousVariableType) || $this->isTypeToIgnore($newVariableType)) {
60+
return [];
61+
}
62+
63+
if (
64+
!$previousVariableType->isSuperTypeOf($newVariableType)->yes() // allow narrowing
65+
&& !$newVariableType->isSuperTypeOf($previousVariableType)->yes() // allow generalization
66+
) {
67+
return ["Overwriting variable \$$variableName while changing its type from {$previousVariableType->describe(VerbosityLevel::precise())} to {$newVariableType->describe(VerbosityLevel::precise())}"];
68+
}
69+
70+
return [];
71+
}
72+
73+
private function generalize(Type $type): Type
74+
{
75+
if (
76+
$type instanceof ConstantType
77+
|| $type instanceof IntegerRangeType
78+
|| $type instanceof EnumCaseObjectType
79+
|| $type instanceof UnionType // e.g. 'foo'|'bar' -> string or int<min, -1>|int<1, max> -> int
80+
) {
81+
$type = $type->generalize(GeneralizePrecision::lessSpecific());
82+
}
83+
84+
if ($type instanceof NullType) {
85+
return $type;
86+
}
87+
88+
return $this->removeNullAccessoryAndSubtractedTypes($type);
89+
}
90+
91+
private function isTypeToIgnore(Type $type): bool
92+
{
93+
return $type instanceof NullType || $type instanceof MixedType;
94+
}
95+
96+
private function removeNullAccessoryAndSubtractedTypes(Type $type): Type
97+
{
98+
if ($type instanceof NullType) {
99+
return $type;
100+
}
101+
102+
if ($type instanceof IntersectionType) {
103+
$newInnerTypes = [];
104+
105+
foreach ($type->getTypes() as $innerType) {
106+
if ($innerType instanceof AccessoryType) { // @phpstan-ignore-line ignore bc promise
107+
continue;
108+
}
109+
110+
$newInnerTypes[] = $innerType;
111+
}
112+
113+
$type = TypeCombinator::intersect(...$newInnerTypes);
114+
}
115+
116+
if ($type instanceof ObjectType) {
117+
$type = $type->changeSubtractedType(null);
118+
}
119+
120+
return TypeCombinator::removeNull($type);
121+
}
122+
123+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\PHPStan\Rule;
4+
5+
use PHPStan\Rules\Rule;
6+
use ShipMonk\PHPStan\RuleTestCase;
7+
8+
/**
9+
* @extends RuleTestCase<ForbidVariableTypeOverwritingRule>
10+
*/
11+
class ForbidVariableTypeOverwritingRuleTest extends RuleTestCase
12+
{
13+
14+
protected function getRule(): Rule
15+
{
16+
return new ForbidVariableTypeOverwritingRule();
17+
}
18+
19+
public function testClass(): void
20+
{
21+
$this->analyseFile(__DIR__ . '/data/ForbidVariableTypeOverwritingRule/code.php');
22+
}
23+
24+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php
2+
3+
namespace ForbidVariableTypeOverwritingRule;
4+
5+
interface SomeInterface {
6+
7+
}
8+
9+
class ParentClass {
10+
11+
}
12+
13+
class ChildClass1 extends ParentClass {
14+
15+
}
16+
17+
class ChildClass2 extends ParentClass {
18+
19+
}
20+
21+
class AnotherClassWithInterface implements SomeInterface {
22+
23+
}
24+
25+
function testGeneralizationAndNarrowing(
26+
object $object,
27+
SomeInterface $interface,
28+
SomeInterface&ParentClass $classWithInterface1,
29+
SomeInterface&ParentClass $classWithInterface2,
30+
SomeInterface&ParentClass $classWithInterface3,
31+
int|string $intOrString1,
32+
int|string $intOrString2,
33+
ParentClass $parentClass,
34+
ChildClass1 $childClass1,
35+
ChildClass2 $childClass2,
36+
) {
37+
$childClass1 = new ParentClass();
38+
$parentClass = new ChildClass2();
39+
$childClass2 = new ChildClass1(); // error: Overwriting variable $childClass2 while changing its type from ForbidVariableTypeOverwritingRule\ChildClass2 to ForbidVariableTypeOverwritingRule\ChildClass1
40+
41+
$object = new ParentClass();
42+
$intOrString1 = 1;
43+
$intOrString2 = []; // error: Overwriting variable $intOrString2 while changing its type from int|string to array{}
44+
$classWithInterface1 = new ParentClass();
45+
$classWithInterface2 = new AnotherClassWithInterface(); // error: Overwriting variable $classWithInterface2 while changing its type from ForbidVariableTypeOverwritingRule\ParentClass&ForbidVariableTypeOverwritingRule\SomeInterface to ForbidVariableTypeOverwritingRule\AnotherClassWithInterface
46+
$classWithInterface3 = $interface;
47+
}
48+
49+
/**
50+
* @param array $array
51+
* @param list<int> $intList
52+
* @param list<ParentClass> $objectList
53+
* @param array<string, string> $map
54+
*/
55+
function testBasics(
56+
array $array,
57+
array $objectList,
58+
string $string,
59+
ParentClass $class,
60+
array $map,
61+
array $intList = [1],
62+
): void {
63+
$intList = ['string']; // error: Overwriting variable $intList while changing its type from array<int, int> to array<int, string>
64+
$array = 1; // error: Overwriting variable $array while changing its type from array to int
65+
$string = 1; // error: Overwriting variable $string while changing its type from string to int
66+
$objectList = ['foo']; // error: Overwriting variable $objectList while changing its type from array<int, ForbidVariableTypeOverwritingRule\ParentClass> to array<int, string>
67+
$class = new \stdClass(); // error: Overwriting variable $class while changing its type from ForbidVariableTypeOverwritingRule\ParentClass to stdClass
68+
$map = [1]; // error: Overwriting variable $map while changing its type from array<string, string> to array<int, int>
69+
}
70+
71+
function testIgnoredTypes(
72+
mixed $mixed1,
73+
mixed $mixed2,
74+
mixed $mixed3,
75+
mixed $mixed4,
76+
?ParentClass $parentClass1,
77+
ParentClass $parentClass2,
78+
): void {
79+
$null = null;
80+
$null = '';
81+
$mixed1 = '';
82+
$mixed2 = 1;
83+
$mixed3 = null;
84+
$parentClass1 = null;
85+
$parentClass2 = $mixed4;
86+
}
87+
88+
/**
89+
* @param positive-int $positiveInt
90+
* @param int-mask-of<1|2|4> $intMask
91+
* @param list<int> $intList
92+
* @param 'foo'|'bar' $stringUnion
93+
* @param non-empty-string $nonEmptyString
94+
* @param non-empty-array<mixed> $nonEmptyArray
95+
* @param numeric-string $numericString
96+
* @param array<'key1'|'key2', class-string> $strictArray
97+
*/
98+
function testAdvancedTypesAreIgnored(
99+
array $nonEmptyArray,
100+
array $intList,
101+
mixed $mixed,
102+
int $int,
103+
int $positiveInt,
104+
int $intMask,
105+
string $string,
106+
string $stringUnion,
107+
string $nonEmptyString,
108+
string $numericString,
109+
array $strictArray,
110+
): void {
111+
$positiveInt = $int;
112+
$intMask = $int;
113+
$stringUnion = $string;
114+
$nonEmptyArray = ['string'];
115+
$intList = [1];
116+
$mixed = $nonEmptyArray['unknown'];
117+
$nonEmptyString = ' ';
118+
$numericString = 'not-a-number';
119+
$strictArray = ['string' => 'string'];
120+
}
121+
122+
function testSubtractedTypeNotKept(ParentClass $someClass) {
123+
if (!$someClass instanceof ChildClass1) {
124+
$someClass = new ChildClass1();
125+
}
126+
}

0 commit comments

Comments
 (0)