diff --git a/composer.json b/composer.json index 1d3c7b1..f83c342 100644 --- a/composer.json +++ b/composer.json @@ -11,11 +11,11 @@ ], "require": { "php": "^7.4", - "phpstan/phpstan": "^1.9" + "phpstan/phpstan": "^2" }, "require-dev": { "cakephp/cakephp": "^2.10.24", - "phpstan/phpstan-phpunit": "^1.3", + "phpstan/phpstan-phpunit": "^2", "phpunit/phpunit": "^9.6", "nunomaduro/phpinsights": "^2.7" }, @@ -63,6 +63,12 @@ "config": { "allow-plugins": { "dealerdirect/phpcodesniffer-composer-installer": true + }, + "audit": { + "ignore": { + "CVE-2015-8379": "CakePHP code is tested, not run", + "CVE-2020-15400": "CakePHP code is tested, not run" + } } } } diff --git a/docs/Components.md b/docs/Components.md new file mode 100644 index 0000000..ef9dbf1 --- /dev/null +++ b/docs/Components.md @@ -0,0 +1,11 @@ +# Components Extension + +Components have the following magic properties which need mapping for PHPStan: + +* [Components](#components) + +## Components + +Components listed in `$components` are available in components at `$this->{$componentName}`. + +TODO: Limit components to those listed in `$components`. diff --git a/docs/Controllers.md b/docs/Controllers.md new file mode 100644 index 0000000..69ea088 --- /dev/null +++ b/docs/Controllers.md @@ -0,0 +1,31 @@ +# Controllers Extension + +Controllers have the following magic properties which need mapping for PHPStan: + +* [Models](#models) +* [Components](#components) + +## Models + +All models listed in `$uses` are available at `$this->{$modelName}`. If `$uses` is not set, include the primary model +for the controller. If `uses` is not `false` models listed in `AppController::$uses` are also available. + +See https://book.cakephp.org/2.x/controllers.html#components-helpers-and-uses. + +TODO: Need to limit models to those listed in the controller's `$uses` property + +## Components + +All components listed in `$components` are available at `$this->{$componentName}`. If `$components` is not `false` then +components listed in `AppController::$components` are also available. + +See https://book.cakephp.org/2.x/controllers.html#components-helpers-and-uses. + +TODO: Need to limit components to those listed in the controller's `$components` property + +## Load Model + +`loadModel($modelClass)` sets `$this->{$modelClass}` to be an instance of that model class. + +TODO: Need to implement, or (if not practical to implement) add a rule to flag its usage as a failure so that it is either +set in `$uses` or replaced with a call to `ClassRegistry::init($modelClass)` to get an instance. diff --git a/docs/Helpers.md b/docs/Helpers.md new file mode 100644 index 0000000..7286fbc --- /dev/null +++ b/docs/Helpers.md @@ -0,0 +1,11 @@ +# Helpers Extension + +Helpers have the following magic properties which need mapping for PHPStan: + +* [Helpers](#helpers) + +## Helpers + +Helpers listed in `$helpers` are available in helpers at `$this->{$helperName}`. + +TODO: Limit helpers to those listed in `$helpers`. diff --git a/docs/Models.md b/docs/Models.md new file mode 100644 index 0000000..8d5d5d1 --- /dev/null +++ b/docs/Models.md @@ -0,0 +1,75 @@ +# Models Extension + +CakePHP 2 models have the following magic methods/properties which need mapping for PHPStan: + +* [Associations](#associations) +* [Behavior Methods](#behavior-methods) +* [Behavior Mapped Methods](#behavior-mapped-methods) +* [Custom Find Types](#custom-find-types) +* [Find By](#find-by) +* [Find All By](#find-all-by) +* [Find Custom By](#find-custom-by) + +## Associations + +All models listed in `$hasOne`, `$hasMany`, `$belongsTo`, and `$hasAndBelongsToMany` are available at +`$this->{$modelName}`. + +See https://book.cakephp.org/2.x/models/associations-linking-models-together.html. + +TODO: Need to limit models to those listed in the model's `$hasOne`, `$hasMany`, `$belongsTo`, and +`$hasAndBelongsToMany` properties + +## Behavior Methods + +All methods from behaviors listed in `$actsAs` are available in this model (without the first `$model` parameter). +Methods listed in `AppModel` are also available. + +See https://book.cakephp.org/2.x/models/behaviors.html. + +TODO: Need to load behaviors from the `AppModel::$actsAs` + +## Behavior Mapped Methods + +All methods from behaviors listed in `$mapMethods` are available in this model as magic methods (without the first +`$model` parameter). + +See https://book.cakephp.org/2.x/models/behaviors.html#mapped-methods. + +TODO: Need to load implement + +## Custom Find Types + +All find types listed in `$findMethods` cause the `find()` method to return the type returned by their respective +`_find{$type}` method. + +See https://book.cakephp.org/2.x/models/retrieving-your-data.html#creating-custom-find-types. + +TODO: Need to implement + +## Find By + +Methods like `findBy{$fieldName}($fieldValue, $fields, $order)` or +`findBy{$field1Name}And{$field2Name}($field1Value, $field2Name, $fields, $order)` work like `find('first')`. + +See https://book.cakephp.org/2.x/models/retrieving-your-data.html#findby. + +TODO: Need to implement + +## Find All By + +Methods like `findAllBy{$fieldName}($fieldValue, $fields, $order)` or +`findAllBy{$field1Name}And{$field2Name}($field1Value, $field2Name, $fields, $order)` work like `find('all')`. + +See https://book.cakephp.org/2.x/models/retrieving-your-data.html#findallby. + +TODO: Need to implement (probably a specific case of [Find Custom By](#find-custom-by)) + +## Find Custom By + +Methods like `findCustomBy{$fieldName}($fieldValue, $fields, $order)` or +`findCustomBy{$field1Name}And{$field2Name}($field1Value, $field2Name, $fields, $order)` work like `find('custom')`. + +See https://book.cakephp.org/2.x/models/retrieving-your-data.html#custom-magic-finders. + +TODO: Need to implement diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..403407a --- /dev/null +++ b/docs/README.md @@ -0,0 +1,73 @@ +# Available Extensions + +## MVC Extensions + +PHPStan extensions are available for several MVC classes in CakePHP to help PHPStan understand the otherwise +undocumented methods and properties of these classes. The links covered are shown in the diagram below. + +```mermaid +classDiagram + namespace CakePHP { + class Shell { + <> + string[] tasks + string[] uses + } + class Model { + <> + string[] actsAs + array hasOne + array hasMany + array belongsTo + array hasAndBelongsToMany + array findMethods + array mapMethods + } + class Behavior { + <> + } + class Controller { + <> + string[] components + string[] helpers + string[] uses + } + class Component { + <> + string[] components + } + class Helper { + <> + string[] helpers + } + } + namespace App { + class AppShell { + <> + } + class AppModel { + <> + } + class AppController { + <> + } + class AppHelper { + <> + } + } + AppShell --|> Shell + Shell --> "*" Shell + Shell --> "*" Model + + AppModel --|> Model + Model --> "*" Behavior + + AppController --|> Controller + Controller --> "*" Model + Controller --> "*" Component + Controller --> "*" Helper + Component --> "*" Component + + AppHelper --|> Helper + Helper --> "*" Helper +``` diff --git a/docs/Shells.md b/docs/Shells.md new file mode 100644 index 0000000..51ec9c0 --- /dev/null +++ b/docs/Shells.md @@ -0,0 +1,22 @@ +# Shells Extension + +Shells have the following magic properties which need mapping for PHPStan: + +* [Models](#models) +* [Tasks](#tasks) + +## Models + +See https://book.cakephp.org/2.x/console-and-shells.html#using-models-in-your-shells. + +All models listed in `$uses` are available at `$this->{$modelName}`. + +TODO: Need to limit models to those listed in the shell's `$uses` property + +## Tasks + +See https://book.cakephp.org/2.x/console-and-shells.html#shell-tasks. + +All tasks listed in `$tasks` are available at `$this->{$taskName}`. + +TODO: Need to limit tasks to those listed in the shell's `$tasks` property diff --git a/phpinsights.php b/phpinsights.php index afe89f2..5f413c2 100644 --- a/phpinsights.php +++ b/phpinsights.php @@ -55,6 +55,7 @@ 'exclude' => [ 'phpstan', + 'stubs', 'tests', 'vendor', ], diff --git a/src/ClassReflectionFinder.php b/src/ClassReflectionFinder.php index a4be4ef..d32e96c 100644 --- a/src/ClassReflectionFinder.php +++ b/src/ClassReflectionFinder.php @@ -66,7 +66,10 @@ private function getClassNamesFromPaths( } $classPaths = array_merge($classPaths, $filePaths); } - $classNames = array_map($pathToClassName ?? [$this, 'getClassNameFromFileName'], $classPaths); + $classNames = array_map( + $pathToClassName ?? [$this, 'getClassNameFromFileName'], + $classPaths + ); return array_filter( $classNames, [$this->reflectionProvider, 'hasClass'] diff --git a/src/ClassRegistryInitExtension.php b/src/ClassRegistryInitExtension.php index baf120d..e81f48b 100644 --- a/src/ClassRegistryInitExtension.php +++ b/src/ClassRegistryInitExtension.php @@ -1,22 +1,27 @@ reflectionProvider = $reflectionProvider; $this->schemaService = $schemaService; } @@ -35,13 +40,22 @@ public function getClass(): string return 'ClassRegistry'; } - public function isStaticMethodSupported(MethodReflection $methodReflection): bool - { + public function isStaticMethodSupported( + MethodReflection $methodReflection + ): bool { return $methodReflection->getName() === 'init'; } - public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type - { + /** + * @throws ShouldNotHappenException + * @throws ConstExprEvaluationException + * @throws Exception + */ + public function getTypeFromStaticMethodCall( + MethodReflection $methodReflection, + StaticCall $methodCall, + Scope $scope + ): Type { $arg1 = $methodCall->getArgs()[0]->value; $evaluator = new ConstExprEvaluator(); $arg1 = $evaluator->evaluateSilently($arg1); @@ -54,14 +68,18 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, if ($this->schemaService->hasTable(Inflector::tableize($arg1))) { return new ObjectType('Model'); } + return $this->getDefaultType(); } + /** + * @throws ShouldNotHappenException + */ private function getDefaultType(): Type { return new UnionType([ new BooleanType(), - new ObjectWithoutClassType() + new ObjectWithoutClassType(), ]); } } diff --git a/src/LoadComponentOnFlyMethodReturnTypeExtension.php b/src/LoadComponentOnFlyMethodReturnTypeExtension.php index 060e878..900a01c 100644 --- a/src/LoadComponentOnFlyMethodReturnTypeExtension.php +++ b/src/LoadComponentOnFlyMethodReturnTypeExtension.php @@ -5,16 +5,17 @@ namespace ARiddlestone\PHPStanCakePHP2; use Component; +use ComponentCollection; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Scalar\String_; use PHPStan\Analyser\Scope; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ReflectionProvider; -use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\DynamicMethodReturnTypeExtension as ReturnTypeExtension; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; -class LoadComponentOnFlyMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension +final class LoadComponentOnFlyMethodReturnTypeExtension implements ReturnTypeExtension { private ReflectionProvider $reflectionProvider; @@ -25,7 +26,7 @@ public function __construct(ReflectionProvider $reflectionProvider) public function getClass(): string { - return \ComponentCollection::class; + return ComponentCollection::class; } public function isMethodSupported(MethodReflection $methodReflection): bool @@ -33,21 +34,28 @@ public function isMethodSupported(MethodReflection $methodReflection): bool return $methodReflection->getName() === 'load'; } - public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type - { + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): ?Type { $arg = $methodCall->getArgs()[0]->value; - if (!$arg instanceof String_) { + if (! $arg instanceof String_) { return null; } $componentName = $arg->value . 'Component'; - if (!$this->reflectionProvider->hasClass($componentName)) { + if (! $this->reflectionProvider->hasClass($componentName)) { return null; } - if (!$this->reflectionProvider->getClass($componentName)->is(Component::class)) { + if ( + ! $this->reflectionProvider + ->getClass($componentName) + ->is(Component::class) + ) { return null; } diff --git a/src/ModelBehaviorsExtension.php b/src/ModelBehaviorsExtension.php index f025d52..532e27c 100644 --- a/src/ModelBehaviorsExtension.php +++ b/src/ModelBehaviorsExtension.php @@ -5,6 +5,10 @@ namespace ARiddlestone\PHPStanCakePHP2; use Exception; +use Model; +use PhpParser\Node\ArrayItem; +use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Scalar\String_; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\MethodsClassReflectionExtension; @@ -18,17 +22,22 @@ final class ModelBehaviorsExtension implements MethodsClassReflectionExtension /** * @var ReflectionProvider */ - private $reflectionProvider; + private ReflectionProvider $reflectionProvider; /** * @var array */ - private $behaviorPaths; + private array $behaviorPaths; /** - * @var array|null + * @var array> */ - private $behaviorMethods = null; + private array $behaviorMethods = []; + + /** + * @var array> + */ + private array $modelBehaviorNames = []; /** * @param array $behaviorPaths @@ -52,18 +61,21 @@ public function hasMethod( && in_array( $methodName, array_map( - [$this, 'getMethodReflectionName'], - $this->getBehaviorMethods() + fn(MethodReflection $methodReflection) => $methodReflection->getName(), + $this->getModelBehaviorMethods($classReflection) ) ); } + /** + * @throws Exception + */ public function getMethod( ClassReflection $classReflection, string $methodName ): MethodReflection { $methodReflections = array_filter( - $this->getBehaviorMethods(), + $this->getModelBehaviorMethods($classReflection), static function ( MethodReflection $methodReflection ) use ($methodName) { @@ -77,41 +89,75 @@ static function ( } /** - * Gets all behavior methods from known behavior classes. - * - * @return array + * @param ClassReflection $modelReflection + * @return list + */ + private function getModelBehaviorMethods(ClassReflection $modelReflection): array + { + $behaviorNames = $this->getModelBehaviorNames($modelReflection); + return array_merge(...array_map([$this, 'getBehaviorMethods'], $behaviorNames)); + } + + /** + * @param ClassReflection $modelReflection + * @return list + */ + private function getModelBehaviorNames(ClassReflection $modelReflection): array + { + if (! array_key_exists($modelReflection->getName(), $this->modelBehaviorNames)) { + $phpReflection = $modelReflection->getNativeReflection(); + if (!$phpReflection->hasProperty('actsAs')) { + // TODO merge parent class actsAs? + return []; + } + $actsAs = $phpReflection->getProperty('actsAs')->getDefaultValueExpression(); + if (!$actsAs instanceof Array_) { + return []; + } + + $this->modelBehaviorNames[$modelReflection->getName()] = array_values( + array_filter( + array_map( + fn(ArrayItem $item) => $item->value instanceof String_ ? $item->value->value : null, + $actsAs->items + ) + ) + ); + } + + return $this->modelBehaviorNames[$modelReflection->getName()]; + } + + /** + * Gets all behavior methods for a given behavior name. * + * @param string $behaviorName + * @return list * @throws Exception */ - private function getBehaviorMethods(): array + private function getBehaviorMethods(string $behaviorName): array { - if ($this->behaviorMethods === null) { + if (! array_key_exists($behaviorName, $this->behaviorMethods)) { + $this->behaviorMethods[$behaviorName] = []; + $classReflectionFinder = new ClassReflectionFinder( $this->reflectionProvider ); - $classReflections = $classReflectionFinder->getClassReflections( - $this->behaviorPaths, - 'ModelBehavior' + $reflectionClass = array_find( + $classReflectionFinder->getClassReflections( + $this->behaviorPaths, + $behaviorName.'Behavior', + ), + fn(ClassReflection $class) => $class->getName() === $behaviorName.'Behavior', ); - $this->behaviorMethods = []; - foreach ($classReflections as $classReflection) { - $modelBehaviorMethodExtractor = - new ModelBehaviorMethodExtractor($classReflection); - $this->behaviorMethods = array_merge( - $this->behaviorMethods, - $modelBehaviorMethodExtractor->getModelBehaviorMethods() - ); + if (!$reflectionClass) { + return []; } + $modelBehaviorMethodExtractor = + new ModelBehaviorMethodExtractor($reflectionClass); + $this->behaviorMethods[$behaviorName] = array_values($modelBehaviorMethodExtractor->getModelBehaviorMethods()); } - return $this->behaviorMethods; - } - /** - * Returns the name of a method from its reflection. - */ - private function getMethodReflectionName( - MethodReflection $methodReflection - ): string { - return $methodReflection->getName(); + return $this->behaviorMethods[$behaviorName]; } } diff --git a/src/Service/SchemaService.php b/src/Service/SchemaService.php index f9890aa..906959e 100644 --- a/src/Service/SchemaService.php +++ b/src/Service/SchemaService.php @@ -1,10 +1,13 @@ + * @var list */ private array $schemaPaths; @@ -30,8 +33,12 @@ final class SchemaService private ?array $tableSchemas = null; /** - * @param ReflectionProvider $reflectionProvider - * @param array $schemaPaths + * @var list|null + */ + private ?array $cakeSchemaPropertyNames = null; + + /** + * @param list $schemaPaths */ public function __construct( ReflectionProvider $reflectionProvider, @@ -50,13 +57,14 @@ public function hasTable(string $table): bool } /** - * @param string $table * @return table_schema|null + * * @throws Exception */ public function getTableSchema(string $table) { $tableSchemas = $this->getTableSchemas(); + return array_key_exists($table, $tableSchemas) ? $tableSchemas[$table] : null; @@ -72,41 +80,67 @@ private function getTableSchemas(): array if (is_array($this->tableSchemas)) { return $this->tableSchemas; } - $cakeSchemaPropertyNames = array_map( - function (ReflectionProperty $reflectionProperty) { - return $reflectionProperty->getName(); - }, - $this->reflectionProvider->getClass('CakeSchema')->getNativeReflection()->getProperties() - ); $this->tableSchemas = []; $classReflectionFinder = new ClassReflectionFinder( - $this->reflectionProvider + $this->reflectionProvider, ); $schemaReflections = $classReflectionFinder->getClassReflections( $this->schemaPaths, 'CakeSchema', function (string $fileName) { return $this->fileNameToClassName($fileName); - } + }, ); foreach ($schemaReflections as $schemaReflection) { - $propertyNames = array_map( - function (ReflectionProperty $reflectionProperty) { - return $reflectionProperty->getName(); - }, - $schemaReflection->getNativeReflection() - ->getProperties(CoreReflectionProperty::IS_PUBLIC) - ); - $tableProperties = array_diff($propertyNames, $cakeSchemaPropertyNames); - $this->tableSchemas += array_intersect_key( - $schemaReflection->getNativeReflection()->getDefaultProperties(), - array_fill_keys($tableProperties, null) + $this->tableSchemas += $this->extractTableSchemas( + $schemaReflection, ); } return $this->tableSchemas; } + /** + * @return list + */ + private function getCakeSchemaPropertyNames(): array + { + return $this->cakeSchemaPropertyNames ??= array_map( + static function (ReflectionProperty $reflectionProperty) { + return $reflectionProperty->getName(); + }, + $this->reflectionProvider + ->getClass('CakeSchema') + ->getNativeReflection() + ->getProperties(), + ); + } + + /** + * @return array + */ + private function extractTableSchemas( + ClassReflection $schemaReflection + ): array { + $propertyNames = array_map( + static function (ReflectionProperty $reflectionProperty) { + return $reflectionProperty->getName(); + }, + $schemaReflection + ->getNativeReflection() + ->getProperties(CoreReflectionProperty::IS_PUBLIC), + ); + $tableProperties = array_diff( + $propertyNames, + $this->getCakeSchemaPropertyNames(), + ); + + return array_intersect_key( + $schemaReflection->getNativeReflection()->getDefaultProperties(), + array_fill_keys($tableProperties, null), + ); + } + private function fileNameToClassName(string $fileName): string { return str_replace( @@ -116,9 +150,9 @@ private function fileNameToClassName(string $fileName): string str_replace( ['_', '-'], ' ', - basename($fileName, '.php') - ) - ) - ) . 'Schema'; + basename($fileName, '.php'), + ), + ), + ).'Schema'; } } diff --git a/tests/Feature/ModelExtensionsTest.php b/tests/Feature/ModelExtensionsTest.php index 60da68e..cfb7f6f 100644 --- a/tests/Feature/ModelExtensionsTest.php +++ b/tests/Feature/ModelExtensionsTest.php @@ -15,6 +15,7 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/custom_model_behavior.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/invalid_model_property.php'); yield from $this->gatherAssertTypes(__DIR__ . '/data/existing_model_model.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/missing_model_behavior.php'); } /** diff --git a/tests/Feature/classes/Model/BasicModel.php b/tests/Feature/classes/Model/BasicModel.php deleted file mode 100644 index ee21f99..0000000 --- a/tests/Feature/classes/Model/BasicModel.php +++ /dev/null @@ -1,3 +0,0 @@ - + */ + public $actsAs = [ + 'Basic', + 'Translate', + ]; +} diff --git a/tests/Feature/classes/Model/ModelWithoutBehaviors.php b/tests/Feature/classes/Model/ModelWithoutBehaviors.php new file mode 100644 index 0000000..cf8f9ef --- /dev/null +++ b/tests/Feature/classes/Model/ModelWithoutBehaviors.php @@ -0,0 +1,3 @@ +bindTranslation([]); assertType('bool', $result); diff --git a/tests/Feature/data/custom_model_behavior.php b/tests/Feature/data/custom_model_behavior.php index 4f664ad..f405132 100644 --- a/tests/Feature/data/custom_model_behavior.php +++ b/tests/Feature/data/custom_model_behavior.php @@ -4,7 +4,7 @@ use function PHPStan\Testing\assertType; -/** @var BasicModel $model */ +/** @var ModelWithBehaviors $model */ $result = $model->behaviorMethod('a string!'); assertType('string', $result); diff --git a/tests/Feature/data/existing_controller_model.php b/tests/Feature/data/existing_controller_model.php index 58af979..b74bfb7 100644 --- a/tests/Feature/data/existing_controller_model.php +++ b/tests/Feature/data/existing_controller_model.php @@ -5,6 +5,6 @@ use function PHPStan\Testing\assertType; /** @var BasicController $controller */ -$model = $controller->BasicModel; +$model = $controller->ModelWithoutBehaviors; -assertType('BasicModel', $model); +assertType('ModelWithoutBehaviors', $model); diff --git a/tests/Feature/data/existing_model_model.php b/tests/Feature/data/existing_model_model.php index 680b7ec..6aa7de3 100644 --- a/tests/Feature/data/existing_model_model.php +++ b/tests/Feature/data/existing_model_model.php @@ -4,7 +4,7 @@ use function PHPStan\Testing\assertType; -/** @var BasicModel $model */ +/** @var ModelWithoutBehaviors $model */ $secondModel = $model->SecondModel; assertType('SecondModel', $secondModel); diff --git a/tests/Feature/data/existing_shell_model.php b/tests/Feature/data/existing_shell_model.php index 5eb8123..991c2b6 100644 --- a/tests/Feature/data/existing_shell_model.php +++ b/tests/Feature/data/existing_shell_model.php @@ -5,6 +5,6 @@ use function PHPStan\Testing\assertType; /** @var BasicShell $shell */ -$model = $shell->BasicModel; +$model = $shell->ModelWithoutBehaviors; -assertType('BasicModel', $model); +assertType('ModelWithoutBehaviors', $model); diff --git a/tests/Feature/data/invalid_model_property.php b/tests/Feature/data/invalid_model_property.php index 2595a9c..1808477 100644 --- a/tests/Feature/data/invalid_model_property.php +++ b/tests/Feature/data/invalid_model_property.php @@ -4,7 +4,7 @@ use function PHPStan\Testing\assertType; -/** @var BasicModel $model */ +/** @var ModelWithoutBehaviors $model */ $result = $model->unknownMethod('One', 'Two'); assertType('*ERROR*', $result); diff --git a/tests/Feature/data/missing_model_behavior.php b/tests/Feature/data/missing_model_behavior.php new file mode 100644 index 0000000..41744ef --- /dev/null +++ b/tests/Feature/data/missing_model_behavior.php @@ -0,0 +1,10 @@ +behaviorMethod('a string!'); + +assertType('*ERROR*', $result);