diff --git a/config.xsd b/config.xsd index a0f96bac8..2668b8252 100644 --- a/config.xsd +++ b/config.xsd @@ -271,6 +271,7 @@ + diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md index e167bc6f8..2e39f7173 100644 --- a/docs/running_psalm/issues.md +++ b/docs/running_psalm/issues.md @@ -1433,6 +1433,25 @@ function foo() : B { } ``` +### MutableDependency + +Emitted when an immutable class inherits from a class or trait not marked immutable + +```php +class MutableParent { + public int $i = 0; + + public function increment() : void { + $this->i++; + } +} + +/** + * @psalm-immutable + */ +final class NotReallyImmutableClass extends MutableParent {} +``` + ### NoValue Emitted when using the result of a function that never returns. diff --git a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php index 0c40b0a2f..6b8006983 100644 --- a/src/Psalm/Internal/Analyzer/ClassAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ClassAnalyzer.php @@ -22,6 +22,7 @@ use Psalm\Issue\MissingConstructor; use Psalm\Issue\MissingImmutableAnnotation; use Psalm\Issue\MissingPropertyType; use Psalm\Issue\MissingTemplateParam; +use Psalm\Issue\MutableDependency; use Psalm\Issue\OverriddenPropertyAccess; use Psalm\Issue\PropertyNotSetInConstructor; use Psalm\Issue\ReservedWord; @@ -329,6 +330,20 @@ class ClassAnalyzer extends ClassLikeAnalyzer } } + if ($storage->mutation_free + && !$parent_class_storage->mutation_free + ) { + if (IssueBuffer::accepts( + new MutableDependency( + $fq_class_name . ' is marked immutable but ' . $parent_fq_class_name . ' is not', + $code_location + ), + $storage->suppressed_issues + $this->getSuppressedIssues() + )) { + // fall through + } + } + if ($codebase->store_node_types) { $codebase->analyzer->addNodeReference( $this->getFilePath(), @@ -1433,6 +1448,18 @@ class ClassAnalyzer extends ClassLikeAnalyzer } } + if ($storage->mutation_free && !$trait_storage->mutation_free) { + if (IssueBuffer::accepts( + new MutableDependency( + $storage->name . ' is marked immutable but ' . $fq_trait_name . ' is not', + new CodeLocation($this, $trait_name) + ), + $storage->suppressed_issues + $this->getSuppressedIssues() + )) { + // fall through + } + } + $trait_file_analyzer = $project_analyzer->getFileAnalyzerForClassLike($fq_trait_name_resolved); $trait_node = $codebase->classlikes->getTraitNode($fq_trait_name_resolved); $trait_aliases = $codebase->classlikes->getTraitAliases($fq_trait_name_resolved); diff --git a/src/Psalm/Issue/MutableDependency.php b/src/Psalm/Issue/MutableDependency.php new file mode 100644 index 000000000..ecef8c883 --- /dev/null +++ b/src/Psalm/Issue/MutableDependency.php @@ -0,0 +1,7 @@ + [ + 'i = $i; + } + } + + /** + * @psalm-immutable + */ + final class NotReallyImmutableClass { + use ImmutableTrait; + }', + ], + 'preventImmutableClassInheritingMutableParent' => [ + 'i = $i; + } + } + + /** + * @psalm-immutable + */ + final class ImmutableClass extends ImmutableParent {}', + ], ]; } @@ -529,6 +567,40 @@ class ImmutableAnnotationTest extends TestCase new Immutable($item);', 'error_message' => 'ImpureArgument', ], + 'preventNonImmutableTraitInImmutableClass' => [ + 'i++; + } + } + + /** + * @psalm-immutable + */ + final class NotReallyImmutableClass { + use MutableTrait; + }', + 'error_message' => 'MutableDependency' + ], + 'preventImmutableClassInheritingMutableParent' => [ + 'i++; + } + } + + /** + * @psalm-immutable + */ + final class NotReallyImmutableClass extends MutableParent {}', + 'error_message' => 'MutableDependency' + ], ]; } }