diff --git a/.github/workflows/test-phpstan-extension.yml b/.github/workflows/test-phpstan-extension.yml new file mode 100644 index 000000000..5e9ccf67d --- /dev/null +++ b/.github/workflows/test-phpstan-extension.yml @@ -0,0 +1,47 @@ +name: PHPStan extension Test + +on: + push: + paths: &paths + - .github/workflows/test-phpstan-extension.yml + - utils/phpstan/** + - phpunit-utils.xml.dist + pull_request: + paths: *paths + schedule: + - cron: '0 0 1,16 * *' + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test-phpstan-rules: + name: Test PHPStan extension + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v2 + with: + composer-options: --prefer-dist + + - name: Install PHPStan + run: | + composer bin phpstan install + + - name: Test + run: vendor/bin/phpunit -c phpunit-utils.xml.dist --bootstrap utils/rector/tests/bootstrap.php --testsuite Phpstan + shell: bash + + - name: Static analysis + run: bin/tools/phpstan/vendor/phpstan/phpstan/phpstan analyse -c utils/phpstan/phpstan.neon + shell: bash diff --git a/.github/workflows/test-rector-rules.yml b/.github/workflows/test-rector-rules.yml index 90b5074f2..27cd2e0ff 100644 --- a/.github/workflows/test-rector-rules.yml +++ b/.github/workflows/test-rector-rules.yml @@ -5,6 +5,7 @@ on: paths: &paths - .github/workflows/test-rector-rules.yml - utils/rector/** + - phpunit-utils.xml.dist pull_request: paths: *paths schedule: @@ -39,7 +40,7 @@ jobs: composer bin rector install - name: Test - run: vendor/bin/phpunit -c phpunit-rector.xml.dist + run: vendor/bin/phpunit -c phpunit-utils.xml.dist --bootstrap utils/rector/tests/bootstrap.php --testsuite Rector shell: bash - name: Static analysis diff --git a/composer.json b/composer.json index 51901d40f..4861bec89 100644 --- a/composer.json +++ b/composer.json @@ -56,7 +56,8 @@ "psr-4": { "Zenstruck\\Foundry\\": "src/", "Zenstruck\\Foundry\\Psalm\\": "utils/psalm", - "Zenstruck\\Foundry\\Utils\\Rector\\": "utils/rector/src/" + "Zenstruck\\Foundry\\Utils\\Rector\\": "utils/rector/src/", + "Zenstruck\\Foundry\\Utils\\PHPStan\\": "utils/phpstan/src/" }, "files": [ "src/functions.php", @@ -69,7 +70,8 @@ "Zenstruck\\Foundry\\Tests\\": ["tests/"], "App\\": "tests/Fixture/Maker/tmp/src", "App\\Tests\\": "tests/Fixture/Maker/tmp/tests", - "Zenstruck\\Foundry\\Utils\\Rector\\Tests\\": "utils/rector/tests/" + "Zenstruck\\Foundry\\Utils\\Rector\\Tests\\": "utils/rector/tests/", + "Zenstruck\\Foundry\\Utils\\PHPStan\\Tests\\": "utils/phpstan/tests/" }, "exclude-from-classmap": ["tests/Fixture/Maker/expected", "utils/rector/tests/**/Fixtures/"] }, diff --git a/phpunit-rector.xml.dist b/phpunit-utils.xml.dist similarity index 74% rename from phpunit-rector.xml.dist rename to phpunit-utils.xml.dist index fd2dd3982..2c71f11f6 100644 --- a/phpunit-rector.xml.dist +++ b/phpunit-utils.xml.dist @@ -3,7 +3,6 @@ @@ -14,8 +13,12 @@ - + ./utils/rector/tests/ + + ./utils/phpstan/tests/ + + diff --git a/utils/phpstan/extension.neon b/utils/phpstan/extension.neon new file mode 100644 index 000000000..1c760583f --- /dev/null +++ b/utils/phpstan/extension.neon @@ -0,0 +1,16 @@ +parameters: + foundry: + cannotCallStaticCreateMethodOnFactoryInstance: true + +parametersSchema: + foundry: structure([ + cannotCallStaticCreateMethodOnFactoryInstance: anyOf(bool(), arrayOf(bool())) + ]) + +conditionalTags: + Zenstruck\Foundry\Utils\PHPStan\CannotCallStaticCreateMethodOnFactoryInstanceRule: + phpstan.rules.rule: %foundry.cannotCallStaticCreateMethodOnFactoryInstance% + +services: + - + class: Zenstruck\Foundry\Utils\PHPStan\CannotCallStaticCreateMethodOnFactoryInstanceRule diff --git a/utils/phpstan/phpstan.neon b/utils/phpstan/phpstan.neon new file mode 100644 index 000000000..c91d16f9f --- /dev/null +++ b/utils/phpstan/phpstan.neon @@ -0,0 +1,11 @@ +parameters: + inferPrivatePropertyTypeFromConstructor: true + checkUninitializedProperties: true + paths: + - ./src + - ./tests + level: 8 + bootstrapFiles: + - ./tests/bootstrap.php + excludePaths: + - ./tests/Fixtures diff --git a/utils/phpstan/src/CannotCallStaticCreateMethodOnFactoryInstanceRule.php b/utils/phpstan/src/CannotCallStaticCreateMethodOnFactoryInstanceRule.php new file mode 100644 index 000000000..f706f43bc --- /dev/null +++ b/utils/phpstan/src/CannotCallStaticCreateMethodOnFactoryInstanceRule.php @@ -0,0 +1,66 @@ + + */ +final class CannotCallStaticCreateMethodOnFactoryInstanceRule implements Rule +{ + private const STATIC_METHODS = [ + 'createOne' => 'create()', + 'createMany' => 'many()->create()', + 'createRange' => 'range()->create()', + 'createSequence' => 'sequence()->create()', + ]; + + public function getNodeType(): string + { + return Node\Expr\StaticCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Identifier + || !$node->class instanceof Node\Expr) { + return []; + } + + $type = $scope->getType($node->class); + + $methodName = $node->name->toString(); + + if ( + !\in_array($methodName, array_keys(self::STATIC_METHODS), true) + || !$type->isObject()->yes() + || !(new ObjectType(Factory::class))->accepts($type, true)->yes() + ) { + return []; + } + + return [ + RuleErrorBuilder::message( + sprintf( + 'Method "%s()" should not be called on an instance.', + $methodName, + ) + ) + ->tip( + sprintf( + 'Call the method statically instead: "SomeFactory::%s()", or use "$someFactory->%s" if you want to call the method on the instance.', + $methodName, + self::STATIC_METHODS[$methodName] + ) + ) + ->identifier('foundry.staticMethodCalledOnInstance') + ->build(), + ]; + } +} diff --git a/utils/phpstan/tests/CannotCallStaticCreateMethodOnFactoryInstance/CannotCallStaticCreateMethodOnFactoryInstanceRuleTest.php b/utils/phpstan/tests/CannotCallStaticCreateMethodOnFactoryInstance/CannotCallStaticCreateMethodOnFactoryInstanceRuleTest.php new file mode 100644 index 000000000..c82c2637a --- /dev/null +++ b/utils/phpstan/tests/CannotCallStaticCreateMethodOnFactoryInstance/CannotCallStaticCreateMethodOnFactoryInstanceRuleTest.php @@ -0,0 +1,58 @@ + + */ +final class CannotCallStaticCreateMethodOnFactoryInstanceRuleTest extends RuleTestCase +{ + protected function getRule(): Rule + { + return new CannotCallStaticCreateMethodOnFactoryInstanceRule(); + } + + public function testInvalidCalls(): void + { + $this->analyse([ + __DIR__ . '/Fixtures/invalid.php.inc', + ], [ + [ + 'Method "createOne()" should not be called on an instance. + 💡 Call the method statically instead: "SomeFactory::createOne()", or use "$someFactory->create()" if you want to call the method on the instance.', + 6, + ], + [ + 'Method "createMany()" should not be called on an instance. + 💡 Call the method statically instead: "SomeFactory::createMany()", or use "$someFactory->many()->create()" if you want to call the method on the instance.', + 7, + ], + [ + 'Method "createRange()" should not be called on an instance. + 💡 Call the method statically instead: "SomeFactory::createRange()", or use "$someFactory->range()->create()" if you want to call the method on the instance.', + 8, + ], + [ + 'Method "createSequence()" should not be called on an instance. + 💡 Call the method statically instead: "SomeFactory::createSequence()", or use "$someFactory->sequence()->create()" if you want to call the method on the instance.', + 9, + ], + [ + 'Method "createOne()" should not be called on an instance. + 💡 Call the method statically instead: "SomeFactory::createOne()", or use "$someFactory->create()" if you want to call the method on the instance.', + 10, + ], + ]); + } + + public function testValidCalls(): void + { + $this->analyse([ + __DIR__ . '/Fixtures/valid.php.inc', + ], []); + } +} diff --git a/utils/phpstan/tests/CannotCallStaticCreateMethodOnFactoryInstance/Fixtures/invalid.php.inc b/utils/phpstan/tests/CannotCallStaticCreateMethodOnFactoryInstance/Fixtures/invalid.php.inc new file mode 100644 index 000000000..9280b4d26 --- /dev/null +++ b/utils/phpstan/tests/CannotCallStaticCreateMethodOnFactoryInstance/Fixtures/invalid.php.inc @@ -0,0 +1,10 @@ +create(); + +'test'::createOne(); +$foo::createOne(); + +(new \stdClass())::createOne(); diff --git a/utils/phpstan/tests/Fixtures/DummyObject.php b/utils/phpstan/tests/Fixtures/DummyObject.php new file mode 100644 index 000000000..4e6287fd7 --- /dev/null +++ b/utils/phpstan/tests/Fixtures/DummyObject.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Utils\PHPStan\Tests\Fixtures; + +class DummyObject +{ + public ?int $id = null; + + private function __construct() + { + } + + public static function new(): static + { + return new self(); + } +} diff --git a/utils/phpstan/tests/Fixtures/DummyObjectFactory.php b/utils/phpstan/tests/Fixtures/DummyObjectFactory.php new file mode 100644 index 000000000..3e2120400 --- /dev/null +++ b/utils/phpstan/tests/Fixtures/DummyObjectFactory.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Zenstruck\Foundry\Utils\PHPStan\Tests\Fixtures; + +use Zenstruck\Foundry\ObjectFactory; + +final class DummyObjectFactory extends ObjectFactory +{ + public static function class(): string + { + return DummyObject::class; + } + + protected function defaults(): array + { + return []; + } +} diff --git a/utils/phpstan/tests/bootstrap.php b/utils/phpstan/tests/bootstrap.php new file mode 100644 index 000000000..eee3782d3 --- /dev/null +++ b/utils/phpstan/tests/bootstrap.php @@ -0,0 +1,13 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +require \dirname(__DIR__).'/../../vendor/autoload.php'; +require \dirname(__DIR__).'/../../bin/tools/phpstan/vendor/autoload.php';