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'
+ ],
];
}
}