PHPStanのカスタムルールをライブラリとして作る
単純なリポジトリ構成の場合はアプリケーションのコードと同じ場所に配置してPHPStanの設定で読み込めば良い
モノレポ構成のプロジェクトだと同じルールを色々な箇所で使い回したい
その場合はライブラリとして実装した方が良いので今回はカスタムルールをライブラリとして作ってみた
code:ディレクトリ構成(txt)
my-project/
├─ lib/
│ ├─ phpstan-custom-rules/
│ │ ├─ src/
│ │ ├─ tests/
│ │ ├─ composer.json
├─ project-api/
├─ project-domain/
プロジェクト全体でPHPStanをレベル9で運用しているので,PHPStanのカスタムルール用のライブラリもレベル9で書いた
ライブラリを作る
以下のような composer.json を作成
関係ない箇所は省いている
code:composer.json
{
"name": "project/phpstan-custom-rules",
"description": "PHPStanのカスタムルール",
"version": "1.0.0",
"require": {
"php": "^8.3",
"phpstan/phpstan": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^11.4"
},
"autoload": {
"psr-4": {
"Project\\PhpStanCustomRules\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Project\\PhpStanCustomRules\\Tests\\": "tests/"
}
},
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
}
}
}
カスタムルールを extension.neon に書いておく
code:extension.neon
rules:
- Project\PhpStanCustomRules\Extensions\ReadonlyDataClassRule
カスタムルールを作る
カスタムルールの書き方はいくつも記事があるので省略
ジェネリクスで@implements Rule<TNodeType>を指定するとprocessNode()の第一引数$nodeの型が推論される
ここら辺を指定しておくと,カスタムルール自体に使うPHPStanもレベル9で運用出来る
code:ReadonlyDataClassRule.php
<?php
declare(strict_types=1);
namespace Project\PhpStanCustomRules\Extensions;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\IdentifierRuleError;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
/**
* @implements Rule<Class_>
*/
class ReadonlyDataClassRule implements Rule
{
private const IDENTIFIER = 'custom.ReadonlyDataClass';
private const DATA_CLASS = 'Spatie\LaravelData\Data';
private const MESSAGE = 'The property of the class that extends the Data class must be readonly.';
public function getNodeType(): string
{
return Class_::class;
}
public function processNode(Node $node, Scope $scope): array
{
if ($node->extends?->toString() !== self::DATA_CLASS) {
return [];
}
return [
...$this->checkProperties($node),
...$this->checkConstructorPromotion($node),
];
}
/**
* @return list<IdentifierRuleError>
*/
private function checkProperties(Class_ $node): array
{
$errors = [];
foreach ($node->getProperties() as $property) {
if ($property->isReadonly()) {
continue;
}
$errors[] = RuleErrorBuilder::message(self::MESSAGE)
->identifier(self::IDENTIFIER)
->line($property->getEndLine())
->build();
}
return $errors;
}
/**
* @return list<IdentifierRuleError>
*/
private function checkConstructorPromotion(Class_ $node): array
{
$constructor = $node->getMethod('__construct');
if ($constructor === null) {
return [];
}
$errors = [];
foreach ($constructor->params as $param) {
if ($param->isReadonly()) {
continue;
}
$errors[] = RuleErrorBuilder::message(self::MESSAGE)
->identifier(self::IDENTIFIER)
->line($param->getEndLine())
->build();
}
return $errors;
}
}
テストを書く
カスタムルールが上手く動作するかのテストを書いておきたい
PHPStanにはテスト用のクラス PHPStan\Testing\RuleTestCase があり,これを使うとPHPUnitでお手軽にテスト出来る
検出されるテストデータと検出されないテストデータを作成し,analyse()メソッドでファイルのパスと想定されるエラー内容を渡すとカスタムルールが動作しているかを確認できる
ジェネリクスで@extends RuleTestCase<TRule>を指定すると良い
code:ReadonlyDataClassRuleTest.php
<?php
declare(strict_types=1);
namespace Project\PhpStanCustomRules\Tests\ReadonlyDataClassRule;
use Project\PhpStanCustomRules\Extensions\ReadonlyDataClassRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
/**
* @extends RuleTestCase<ReadonlyDataClassRule>
*/
class ReadonlyDataClassRuleTest extends RuleTestCase
{
protected function getRule(): Rule
{
return new ReadonlyDataClassRule();
}
/**
* @param list<array{string, int}> $expectedErrors
*/
public function 検出されること(string $file, array $expectedErrors): void
{
$this->analyse(
$expectedErrors,
);
}
/**
* @return array<string, array{file: string, expectedErrors: list<array{string, int}>}>
*/
public static function 検出されるDataProvider(): array
{
$errorMessage = 'The property of the class that extends the Data class must be readonly.';
return [
'readonlyではないプロパティが宣言されている場合に検出される' => [
'file' => __DIR__ . '/Po/HasWritableProperty.php',
'expectedErrors' => [
[
$errorMessage,
13,
],
],
],
'コンストラクタプロパティプロモーションでも検出される' => [
'file' => __DIR__ . '/Po/ConstructorPromotion.php',
'expectedErrors' => [
[
$errorMessage,
18,
],
[
$errorMessage,
19,
],
],
],
];
}
public function 検出されない(string $file): void
{
$this->analyse(
[],
);
}
/**
* @return array<string, array{file: string}>
*/
public static function 検出されないDataProvider(): array
{
return [
'readonlyプロパティの場合に検出されない' => [
'file' => __DIR__ . '/Neg/HasReadonlyProperty.php',
],
'コンストラクタプロパティプロモーションでも検出されない' => [
'file' => __DIR__ . '/Neg/ConstructorPromotion.php',
],
];
}
}
ライブラリとして使う
ライブラリとして使うのはモノレポ構成での他ライブラリの読み込み方の使い方と同じ
repositoriesでパスを指定してrequireに追加するだけ
code:composer.json
{
"name": "project/domain",
"version": "1.0.0",
"require-dev": {
"project/phpstan-custom-rules": "1.0.0",
"phpstan/extension-installer": "^1.4",
"phpstan/phpstan": "^2.0",
},
"repositories": [
{
"type": "path",
"url": "../lib/phpstan-custom-rules",
"options": {
"symlink": true
}
}
]
}