diff --git a/composer.json b/composer.json index 0de9713..f2a3b28 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ ], "require": { "php": ">=8.1.0", - "phpstan/phpstan": "^1.10", + "phpstan/phpstan": "^1.12", "cakephp/cakephp": "^5.0" }, "require-dev": { diff --git a/extension.neon b/extension.neon index 6f08bd6..7d8faa2 100644 --- a/extension.neon +++ b/extension.neon @@ -62,3 +62,11 @@ services: class: CakeDC\PHPStan\PhpDoc\TableAssociationTypeNodeResolverExtension tags: - phpstan.phpDoc.typeNodeResolverExtension + - + factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\Mailer\MailerAwareTrait, getMailer, %s\Mailer\%sMailer) + tags: + - phpstan.broker.expressionTypeResolverExtension + - + factory: CakeDC\PHPStan\Type\BaseTraitExpressionTypeResolverExtension(Cake\ORM\Locator\LocatorAwareTrait, fetchTable, %s\Model\Table\%sTable, defaultTable) + tags: + - phpstan.broker.expressionTypeResolverExtension diff --git a/phpstan.neon b/phpstan.neon index b02f413..bd1cfeb 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,3 +6,4 @@ rules: parameters: level: max checkGenericClassInNonGenericObjectType: false + treatPhpDocTypesAsCertain: false diff --git a/src/Constraint/ArrayOfStringStartsWith.php b/src/Constraint/ArrayOfStringStartsWith.php new file mode 100644 index 0000000..68e7a9b --- /dev/null +++ b/src/Constraint/ArrayOfStringStartsWith.php @@ -0,0 +1,86 @@ + + */ + private readonly array $actual; + /** + * @var array + */ + private array $result = []; + /** + * @var array + */ + private array $notExpected = []; + + /** + * @param array $actual + */ + public function __construct(array $actual) + { + $this->actual = $actual; + } + + /** + * @return string + */ + public function toString(): string + { + return 'a list of errors'; + } + + /** + * @param mixed $other + * @return bool + */ + protected function matches(mixed $other): bool + { + $result = true; + $this->notExpected = $this->actual; + assert(is_array($other)); + foreach ($other as $key => $error) { + if (!isset($this->actual[$key])) { + $this->result[$key] = ['expected' => $error, 'type' => 'missing', 'actual' => null]; + $result = false; + continue; + } + unset($this->notExpected[$key]); + if (!str_starts_with($this->actual[$key], $error)) { + $this->result[$key] = ['expected' => $error, 'type' => 'not-equal', 'actual' => $this->actual[$key]]; + $result = false; + } + } + + return $result && empty($this->notExpected); + } + + /** + * @param mixed $other + * @return string + */ + protected function failureDescription(mixed $other): string + { + $text = "\n"; + foreach ($this->result as $item) { + if ($item['type'] === 'not-equal') { + $text .= sprintf(" -%s \n +%s \n", $item['expected'], $item['actual']); + } + if ($item['type'] === 'missing') { + $text .= sprintf(" -%s \n", $item['expected']); + } + } + + foreach ($this->notExpected as $item) { + $text .= sprintf(" \n +%s", $item); + } + + return $text; + } +} diff --git a/src/Rule/Traits/AnalyseCheckLineStartsWithTrait.php b/src/Rule/Traits/AnalyseCheckLineStartsWithTrait.php new file mode 100644 index 0000000..61a48ec --- /dev/null +++ b/src/Rule/Traits/AnalyseCheckLineStartsWithTrait.php @@ -0,0 +1,39 @@ +gatherAnalyserErrors($files); + $messageText = static function (int $line, string $message): string { + return sprintf('%02d: %s', $line, $message); + }; + $actualErrors = array_map(static function (Error $error) use ($messageText): string { + $line = $error->getLine(); + if ($line === null) { + return $messageText(-1, $error->getMessage()); + } + + return $messageText($line, $error->getMessage()); + }, $actualErrors); + + $expected = array_map(static function (array $item) use ($messageText): string { + return $messageText($item[1], $item[0]); + }, $expected); + $this->assertThat($expected, new ArrayOfStringStartsWith($actualErrors)); + } +} diff --git a/src/Type/BaseTraitExpressionTypeResolverExtension.php b/src/Type/BaseTraitExpressionTypeResolverExtension.php new file mode 100644 index 0000000..f2e6a8c --- /dev/null +++ b/src/Type/BaseTraitExpressionTypeResolverExtension.php @@ -0,0 +1,128 @@ +name instanceof Identifier + || $expr->name->toString() !== $this->methodName + ) { + return null; + } + + $callerType = $scope->getType($expr->var); + if (!$callerType instanceof ThisType && !$callerType instanceof ObjectType) { + return null; + } + $reflection = $callerType->getClassReflection(); + if ($reflection === null || !$this->isFromTargetTrait($reflection)) { + return null; + } + + $value = $expr->getArgs()[0]->value ?? null; + $baseName = $this->getBaseName($value, $reflection); + if ($baseName === null) { + return null; + } + $className = CakeNameRegistry::getClassName($baseName, $this->namespaceFormat); + if ($className !== null) { + return new ObjectType($className); + } + + return null; + } + + /** + * @param \PHPStan\Reflection\ClassReflection $reflection + * @return bool + */ + protected function isFromTargetTrait(ClassReflection $reflection): bool + { + foreach ($reflection->getTraits() as $trait) { + if ($trait->getName() === $this->targetTrait) { + return true; + } + } + foreach ($reflection->getParents() as $parent) { + if ($this->isFromTargetTrait($parent)) { + return true; + } + } + + return false; + } + + /** + * @param \PhpParser\Node\Expr|null $value + * @param \PHPStan\Reflection\ClassReflection $reflection + * @return string|null + */ + protected function getBaseName(?Expr $value, ClassReflection $reflection): ?string + { + if ($value instanceof String_) { + return $value->value; + } + + try { + if ($value === null && $this->propertyDefaultValue) { + $value = $reflection->getNativeReflection() + ->getProperty($this->propertyDefaultValue) + ->getDefaultValue(); + + return is_string($value) ? $value : null; + } + } catch (ReflectionException) { + return null; + } + + return null; + } +} diff --git a/tests/TestCase/Rule/Model/AddAssociationMatchOptionsTypesRuleTest.php b/tests/TestCase/Rule/Model/AddAssociationMatchOptionsTypesRuleTest.php index 256a2dd..68bc6f6 100644 --- a/tests/TestCase/Rule/Model/AddAssociationMatchOptionsTypesRuleTest.php +++ b/tests/TestCase/Rule/Model/AddAssociationMatchOptionsTypesRuleTest.php @@ -3,8 +3,8 @@ namespace CakeDC\PHPStan\Test\TestCase\Rule\Model; -use Cake\Core\Configure; use CakeDC\PHPStan\Rule\Model\AddAssociationMatchOptionsTypesRule; +use CakeDC\PHPStan\Rule\Traits\AnalyseCheckLineStartsWithTrait; use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; @@ -12,6 +12,8 @@ class AddAssociationMatchOptionsTypesRuleTest extends RuleTestCase { + use AnalyseCheckLineStartsWithTrait; + /** * @return \PHPStan\Rules\Rule */ @@ -38,14 +40,7 @@ protected function getRule(): Rule */ public function testRule(): void { - $messageThrough = 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through" (Cake\ORM\Table|string|null) does not accept stdClass.'; - if (version_compare(Configure::version(), '5.0.5', '<')) { - $messageThrough = 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through" (Cake\ORM\Table|string) does not accept stdClass.'; - } - // first argument: path to the example file that contains some errors that should be reported by MyRule - // second argument: an array of expected errors, - // each error consists of the asserted error message, and the asserted error file line - $this->analyse([__DIR__ . '/Fake/FailingRuleItemsTable.php'], [ + $this->analyseCheckLineStartsWith([__DIR__ . '/Fake/FailingRuleItemsTable.php'], [ [ 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "className" (string) does not accept false.', 66, @@ -67,12 +62,14 @@ public function testRule(): void 66, ], [ - 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "bindingKey" (array|string) does not accept int.', + 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "bindingKey" ', 66, + 'Type #1 from the union: 10 is not a list.', ], [ - 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "foreignKey" (array|string|false) does not accept 11.', + 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "foreignKey" ', 66, + 'Type #1 from the union: 11 is not a list.', ], [ 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsTo with option "joinType" (string) does not accept int.', @@ -107,11 +104,12 @@ public function testRule(): void 85, ], [ - 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "targetForeignKey" (array|string|null) does not accept Closure.', + 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "targetForeignKey" ', 98, + 'Type #1 from the union: Closure(): 10 is not a list.', ], [ - $messageThrough, + 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::belongsToMany with option "through"', 98, ], [ @@ -147,12 +145,14 @@ public function testRule(): void 120, ], [ - 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "bindingKey" (array|string) does not accept int.', + 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "bindingKey" ', 120, + 'Type #1 from the union: 10 is not a list.', ], [ - 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "foreignKey" (array|string|false) does not accept 11.', + 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "foreignKey" ', 120, + 'Type #1 from the union: 11 is not a list.', ], [ 'Call to CakeDC\PHPStan\Test\TestCase\Rule\Model\Fake\FailingRuleItemsTable::hasOne with option "joinType" (string) does not accept int.', @@ -215,12 +215,14 @@ public function testRule(): void 148, ], [ - 'Call to Cake\ORM\AssociationCollection::load with option "bindingKey" (array|string) does not accept int.', + 'Call to Cake\ORM\AssociationCollection::load with option "bindingKey" ', 148, + 'Type #1 from the union: 10 is not a list.', ], [ - 'Call to Cake\ORM\AssociationCollection::load with option "foreignKey" (array|string|false) does not accept 11.', + 'Call to Cake\ORM\AssociationCollection::load with option "foreignKey" ', 148, + 'Type #1 from the union: 11 is not a list.', ], [ 'Call to Cake\ORM\AssociationCollection::load with option "joinType" (string) does not accept int.', diff --git a/tests/test_app/Command/MyTestExtendedCommand.php b/tests/test_app/Command/MyTestExtendedCommand.php new file mode 100644 index 0000000..b4e4ac1 --- /dev/null +++ b/tests/test_app/Command/MyTestExtendedCommand.php @@ -0,0 +1,30 @@ +fetchTable('VeryCustomize00009Articles')->newSample(); + $io->helper('MyHeading')->headingOne('Sample Text 02'); + $this->getMailer('MyTestLoad')->testing(); + } +} diff --git a/tests/test_app/Command/MyTestLoadCommand.php b/tests/test_app/Command/MyTestLoadCommand.php index 42db57a..e94aab1 100644 --- a/tests/test_app/Command/MyTestLoadCommand.php +++ b/tests/test_app/Command/MyTestLoadCommand.php @@ -16,9 +16,12 @@ use Cake\Command\Command; use Cake\Console\Arguments; use Cake\Console\ConsoleIo; +use Cake\Mailer\MailerAwareTrait; class MyTestLoadCommand extends Command { + use MailerAwareTrait; + /** * @inheritDoc */ @@ -29,5 +32,6 @@ public function execute(Arguments $args, ConsoleIo $io) $io->helper('progress')->increment(1); $io->out($io->helper('BazBaz')->foo()); $io->helper('MyHeading')->headingOne('Sample Text 01'); + $this->getMailer('MyTestLoad')->testing(); } } diff --git a/tests/test_app/Mailer/MyTestLoadMailer.php b/tests/test_app/Mailer/MyTestLoadMailer.php index 2401217..5e111d7 100644 --- a/tests/test_app/Mailer/MyTestLoadMailer.php +++ b/tests/test_app/Mailer/MyTestLoadMailer.php @@ -28,4 +28,11 @@ protected function sampleLoading() ->newSample(); $this->viewBuilder()->setVar('article', $article); } + + /** + * @return void + */ + public function testing() + { + } } diff --git a/tests/test_app/Model/Logic/Action/BlockUsers.php b/tests/test_app/Model/Logic/Action/BlockUsers.php new file mode 100644 index 0000000..6c7eac5 --- /dev/null +++ b/tests/test_app/Model/Logic/Action/BlockUsers.php @@ -0,0 +1,40 @@ +fetchTable()->blockOld(); + } +} diff --git a/tests/test_app/Model/Logic/Action/RunBlock.php b/tests/test_app/Model/Logic/Action/RunBlock.php new file mode 100644 index 0000000..9d31c04 --- /dev/null +++ b/tests/test_app/Model/Logic/Action/RunBlock.php @@ -0,0 +1,31 @@ +fetchTable()->blockOld(); + } +} diff --git a/tests/test_app/Model/Logic/Action/WarnUsers.php b/tests/test_app/Model/Logic/Action/WarnUsers.php new file mode 100644 index 0000000..e223892 --- /dev/null +++ b/tests/test_app/Model/Logic/Action/WarnUsers.php @@ -0,0 +1,36 @@ +fetchTable('Notes') + ->warning(); + } +} diff --git a/tests/test_app/Model/Table/UsersTable.php b/tests/test_app/Model/Table/UsersTable.php index 8a1b04c..18b2d8c 100644 --- a/tests/test_app/Model/Table/UsersTable.php +++ b/tests/test_app/Model/Table/UsersTable.php @@ -39,4 +39,11 @@ public function logLastLogin(User $user): User return $this->saveOrFail($user); } + + /** + * @return void + */ + public function blockOld(): void + { + } }