Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

WIP - Limit inheritance to a subset of classes #1450 #9687

Merged
merged 3 commits into from
May 7, 2023
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions config.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@
<xs:element name="InaccessibleClassConstant" type="IssueHandlerType" minOccurs="0" />
<xs:element name="InaccessibleMethod" type="MethodIssueHandlerType" minOccurs="0" />
<xs:element name="InaccessibleProperty" type="IssueHandlerType" minOccurs="0" />
<xs:element name="InheritorViolation" type="ClassIssueHandlerType" minOccurs="0" />
<xs:element name="InterfaceInstantiation" type="IssueHandlerType" minOccurs="0" />
<xs:element name="InternalClass" type="ClassIssueHandlerType" minOccurs="0" />
<xs:element name="InternalMethod" type="MethodIssueHandlerType" minOccurs="0" />
Expand Down
16 changes: 16 additions & 0 deletions docs/annotating_code/supported_annotations.md
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,22 @@ any explicit references to them in your code. You should mark these classes with
class UnreferencedClass {}
```

### `@psalm-inheritors`

Used to tell Psalm that a class can only be extended by a certain subset of classes.

For example,
```php
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
class BaseClass {}
class FooClass extends BaseClass {}
class BarClass extends BaseClass {}
class BazClass extends BaseClass {} // this is an error
```

## Type Syntax

Psalm supports PHPDoc’s [type syntax](https://docs.phpdoc.org/latest/guide/guides/types.html), and also the [proposed PHPDoc PSR type syntax](https://github.com/php-fig/fig-standards/blob/master/proposed/phpdoc.md#appendix-a-types).
Expand Down
1 change: 1 addition & 0 deletions docs/running_psalm/issues.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
- [InaccessibleClassConstant](issues/InaccessibleClassConstant.md)
- [InaccessibleMethod](issues/InaccessibleMethod.md)
- [InaccessibleProperty](issues/InaccessibleProperty.md)
- [InheritorViolation](issues/InheritorViolation.md)
- [InterfaceInstantiation](issues/InterfaceInstantiation.md)
- [InternalClass](issues/InternalClass.md)
- [InternalMethod](issues/InternalMethod.md)
Expand Down
17 changes: 17 additions & 0 deletions docs/running_psalm/issues/InheritorViolation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# InheritorViolation

Emitted when a class/interface using `@psalm-inheritors` is extended/implemented
by a class that does not fulfil it's requirements.

```php
<?php

/**
* @psalm-inheritors FooClass|BarClass
*/
class BaseClass {}
class BazClass extends BaseClass {}
// InheritorViolation is emitted, as BaseClass can only be extended
// by FooClass|BarClass, which is not the case
$a = new BazClass();
```
11 changes: 9 additions & 2 deletions docs/running_psalm/issues/InvalidExtendClass.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# InvalidExtendClass

Emitted when attempting to extend a final class or a class annotated with `@final`.
Emitted when attempting to extend a final class, a class annotated with `@final` or a class using @psalm-inheritors and not in the inheritor list

```php
<?php
Expand All @@ -15,4 +15,11 @@ class B extends A {}
class DoctrineA {}

class DoctrineB extends DoctrineA {}
```

/**
* @psalm-inheritors A|B
*/
class C {}

class D extends C {}
```
2 changes: 1 addition & 1 deletion src/Psalm/DocComment.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

final class DocComment
{
public const PSALM_ANNOTATIONS = [

Check failure on line 21 in src/Psalm/DocComment.php

View workflow job for this annotation

GitHub Actions / Check backward compatibility

Value of constant Psalm\DocComment::PSALM_ANNOTATIONS changed from array ( 0 => 'return', 1 => 'param', 2 => 'template', 3 => 'var', 4 => 'type', 5 => 'template-covariant', 6 => 'property', 7 => 'property-read', 8 => 'property-write', 9 => 'method', 10 => 'assert', 11 => 'assert-if-true', 12 => 'assert-if-false', 13 => 'suppress', 14 => 'ignore-nullable-return', 15 => 'override-property-visibility', 16 => 'override-method-visibility', 17 => 'seal-properties', 18 => 'seal-methods', 19 => 'no-seal-properties', 20 => 'no-seal-methods', 21 => 'ignore-falsable-return', 22 => 'variadic', 23 => 'pure', 24 => 'ignore-variable-method', 25 => 'ignore-variable-property', 26 => 'internal', 27 => 'taint-sink', 28 => 'taint-source', 29 => 'assert-untainted', 30 => 'scope-this', 31 => 'mutation-free', 32 => 'external-mutation-free', 33 => 'immutable', 34 => 'readonly', 35 => 'allow-private-mutation', 36 => 'readonly-allow-private-mutation', 37 => 'yield', 38 => 'trace', 39 => 'import-type', 40 => 'flow', 41 => 'taint-specialize', 42 => 'taint-escape', 43 => 'taint-unescape', 44 => 'self-out', 45 => 'consistent-constructor', 46 => 'stub-override', 47 => 'require-extends', 48 => 'require-implements', 49 => 'param-out', 50 => 'ignore-var', 51 => 'consistent-templates', 52 => 'if-this-is', 53 => 'this-out', 54 => 'check-type', 55 => 'check-type-exact', 56 => 'api', ) to array ( 0 => 'return', 1 => 'param', 2 => 'template', 3 => 'var', 4 => 'type', 5 => 'template-covariant', 6 => 'property', 7 => 'property-read', 8 => 'property-write', 9 => 'method', 10 => 'assert', 11 => 'assert-if-true', 12 => 'assert-if-false', 13 => 'suppress', 14 => 'ignore-nullable-return', 15 => 'override-property-visibility', 16 => 'override-method-visibility', 17 => 'seal-properties', 18 => 'seal-methods', 19 => 'no-seal-properties', 20 => 'no-seal-methods', 21 => 'ignore-falsable-return', 22 => 'variadic', 23 => 'pure', 24 => 'ignore-variable-method', 25 => 'ignore-variable-property', 26 => 'internal', 27 => 'taint-sink', 28 => 'taint-source', 29 => 'assert-untainted', 30 => 'scope-this', 31 => 'mutation-free', 32 => 'external-mutation-free', 33 => 'immutable', 34 => 'readonly', 35 => 'allow-private-mutation', 36 => 'readonly-allow-private-mutation', 37 => 'yield', 38 => 'trace', 39 => 'import-type', 40 => 'flow', 41 => 'taint-specialize', 42 => 'taint-escape', 43 => 'taint-unescape', 44 => 'self-out', 45 => 'consistent-constructor', 46 => 'stub-override', 47 => 'require-extends', 48 => 'require-implements', 49 => 'param-out', 50 => 'ignore-var', 51 => 'consistent-templates', 52 => 'if-this-is', 53 => 'this-out', 54 => 'check-type', 55 => 'check-type-exact', 56 => 'api', 57 => 'inheritors', )
'return', 'param', 'template', 'var', 'type',
'template-covariant', 'property', 'property-read', 'property-write', 'method',
'assert', 'assert-if-true', 'assert-if-false', 'suppress',
Expand All @@ -34,7 +34,7 @@
'taint-unescape', 'self-out', 'consistent-constructor', 'stub-override',
'require-extends', 'require-implements', 'param-out', 'ignore-var',
'consistent-templates', 'if-this-is', 'this-out', 'check-type', 'check-type-exact',
'api',
'api', 'inheritors',
];

/**
Expand Down
19 changes: 19 additions & 0 deletions src/Psalm/Internal/Analyzer/ClassLikeAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Psalm\Internal\Type\TemplateResult;
use Psalm\Internal\Type\TemplateStandinTypeReplacer;
use Psalm\Issue\InaccessibleProperty;
use Psalm\Issue\InheritorViolation;
use Psalm\Issue\InvalidClass;
use Psalm\Issue\InvalidTemplateParam;
use Psalm\Issue\MissingDependency;
Expand All @@ -28,6 +29,7 @@
use Psalm\StatementsSource;
use Psalm\Storage\ClassLikeStorage;
use Psalm\Type;
use Psalm\Type\Atomic\TNamedObject;
use Psalm\Type\Atomic\TTemplateParam;
use Psalm\Type\Union;
use UnexpectedValueException;
Expand Down Expand Up @@ -331,6 +333,23 @@ public static function checkFullyQualifiedClassLikeName(
return null;
}


$classUnion = new Union([new TNamedObject($fq_class_name)]);
foreach ($class_storage->parent_classes + $class_storage->direct_class_interfaces as $parent_class) {
$parent_storage = $codebase->classlikes->getStorageFor($parent_class);
if ($parent_storage && $parent_storage->inheritors) {
if (!UnionTypeComparator::isContainedBy($codebase, $classUnion, $parent_storage->inheritors)) {
IssueBuffer::maybeAdd(
new InheritorViolation(
'Class ' . $fq_class_name . ' is not an allowed inheritor of parent class ' . $parent_class,
$code_location,
),
$suppressed_issues,
);
}
}
}

foreach ($class_storage->invalid_dependencies as $dependency_class_name => $_) {
// if the implemented/extended class is stubbed, it may not yet have
// been hydrated
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,14 @@ public static function parse(
$info->sealed_methods = false;
}

if (isset($parsed_docblock->tags['psalm-inheritors'])) {
foreach ($parsed_docblock->tags['psalm-inheritors'] as $template_line) {
$doc_line_parts = CommentAnalyzer::splitDocLine($template_line);
$doc_line_parts[0] = CommentAnalyzer::sanitizeDocblockType($doc_line_parts[0]);
$info->inheritors = $doc_line_parts[0];
}
}

if (isset($parsed_docblock->tags['psalm-immutable'])
|| isset($parsed_docblock->tags['psalm-mutation-free'])
) {
Expand Down
25 changes: 25 additions & 0 deletions src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,31 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool
$storage->sealed_properties = $docblock_info->sealed_properties;
$storage->sealed_methods = $docblock_info->sealed_methods;


if ($docblock_info->inheritors) {
try {
$storage->inheritors = TypeParser::parseTokens(
TypeTokenizer::getFullyQualifiedTokens(
$docblock_info->inheritors,
$storage->aliases,
$storage->template_types ?? [],
$storage->type_aliases,
$fq_classlike_name,
),
null,
$storage->template_types ?? [],
$storage->type_aliases,
true,
);
} catch (TypeParseTreeException $e) {
$storage->docblock_issues[] = new InvalidDocblock(
'@psalm-inheritors contains invalid reference:' . $e->getMessage(),
$name_location ?? $class_location,
);
}
}


if ($docblock_info->properties) {
foreach ($docblock_info->properties as $property) {
$pseudo_property_type_tokens = TypeTokenizer::getFullyQualifiedTokens(
Expand Down
2 changes: 2 additions & 0 deletions src/Psalm/Internal/Scanner/ClassLikeDocblockComment.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ class ClassLikeDocblockComment
*/
public array $imported_types = [];

public ?string $inheritors = null;

public bool $consistent_constructor = false;

public bool $consistent_templates = false;
Expand Down
9 changes: 9 additions & 0 deletions src/Psalm/Issue/InheritorViolation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Psalm\Issue;

final class InheritorViolation extends CodeIssue
{
public const ERROR_LEVEL = 4;
public const SHORTCODE = 283;
}
5 changes: 5 additions & 0 deletions src/Psalm/Storage/ClassLikeStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,11 @@ final class ClassLikeStorage implements HasAttributesInterface
*/
public $appearing_property_ids = [];

/**
* @var ?Union
*/
public $inheritors = null;

/**
* @var array<string, string>
*/
Expand Down
133 changes: 133 additions & 0 deletions tests/ClassTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,96 @@ private final function __construct() {}
}
PHP,
],
'singleInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass
*/
class BaseClass {}
class FooClass extends BaseClass {}
$a = new FooClass();
PHP,
],
'unionInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
robchett marked this conversation as resolved.
Show resolved Hide resolved
*/
class BaseClass {}
class FooClass extends BaseClass {}
$a = new FooClass();
class BarClass extends FooClass {}
$b = new BarClass();
PHP,
],
'multiInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|arClass
*/
class BaseClass {}
class FooClass extends BaseClass {}
$a = new FooClass();
class BarClass extends FooClass {}
$b = new BarClass();
PHP,
],
'skippedInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
class BaseClass {}
class FooClass extends BaseClass {}
$a = new FooClass();
class BarClass extends FooClass {}
$b = new BarClass();
PHP,
],
'CompositeInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors BarClass&FooInterface
*/
class BaseClass {}
interface FooInterface {}
class BarClass extends BaseClass implements FooInterface {}
$b = new BarClass();
PHP,
],
'InterfaceInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
interface BaseInterface {}
class FooClass implements BaseInterface {}
$a = new FooClass();
class BarClass implements BaseInterface {}
$b = new BarClass();
PHP,
],
'MultiInterfaceInheritorIsAllowed' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
interface InterfaceA {}
/**
* @psalm-inheritors FooClass|BarClass
*/
interface InterfaceB {}
class FooClass implements InterfaceA, InterfaceB {}
$a = new FooClass();
PHP,
],
];
}

Expand Down Expand Up @@ -1293,6 +1383,49 @@ class Bar extends Foo {}
'ignored_issues' => [],
'php_version' => '8.2',
],
'classCannotExtendIfNotInInheritors' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
class BaseClass {}
class BazClass extends BaseClass {} // this is an error
$a = new BazClass();
PHP,
'error_message' => 'InheritorViolation',
'ignored_issues' => [],
],
'classCannotImplementIfNotInInheritors' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass|BarClass
*/
interface BaseInterface {}
class BazClass implements BaseInterface {}
$a = new BazClass();
PHP,
'error_message' => 'InheritorViolation',
'ignored_issues' => [],
],
'UnfulfilledInterfaceInheritors' => [
'code' => <<<'PHP'
<?php
/**
* @psalm-inheritors FooClass
*/
interface InterfaceA {}
/**
* @psalm-inheritors BarClass
*/
interface InterfaceB {}
class BazClass implements InterFaceA, InterFaceB {}
$a = new BazClass();
PHP,
'error_message' => 'InheritorViolation',
'ignored_issues' => [],
],
];
}
}