COLOPL Tech Blog

コロプラのエンジニアブログです

PHPStanのカスタムルールの作り方 - ASTを理解してプロジェクト固有の検査を実装する

こんにちは。バックエンドエンジニアの平野です。

前回の記事では、運用中のタイトルに静的解析を導入してコード品質を継続的に改善した話を紹介しました。

今回はPHPStanの機能のひとつである、ユーザー独自の静的解析ルールを定義できる「カスタムルール」の作り方を解説します。

カスタムルールとは

PHPの静的解析ツールであるPHPStanには、標準のルールセットに加えてユーザー独自の解析ルールを定義できる機能があります。 phpstan.org

カスタムルールを使うことで、以下のような要件をコードレビューではなく自動チェックで検出できるようになります。

  • 特定の関数の使用を禁止したい
  • クラス名に一定の命名規則を持たせたい
  • 依存関係に一定の制約を持たせたい

カスタムルールを作成するメリット

カスタムルールを導入することで、プロジェクトの開発効率とコード品質を向上できます。

プロジェクトのビジネスロジックに特化したルールを定義すれば、標準ルールでは検知できない特有のバグや問題を検出できます。特定のデザインパターンの違反やAPI使用規約の遵守、データベースアクセスパターンの制約なども自動チェックの対象にできます。

また、命名規則や禁止パターンのチェックを自動化すれば、コードレビューの負担を軽減でき、レビュアーはビジネスロジックの妥当性やアーキテクチャ設計といった、より重要な論点に集中できます。

プロジェクトやチーム固有のコーディング規約が自動チェックされることで、新規メンバーのオンボーディングもスムーズになり、コードベース全体の一貫した品質維持にもつながります。

PHPStanの動作原理

カスタムルールを作成するために、まずPHPStanがどのようにコードを解析しているかを理解します。

PHPStanは内部でPHP構文解析器を使用し、プログラムをAST(抽象構文木)という中間表現に変換してコードを解析しています。

ASTとは

ASTとはプログラムにおける演算子や変数、キーワードを木構造で表現したものです。

例えば、nikic/PHP-ParserPHPのプログラムをASTに変換するパーサーで、PHPStanの内部でも使用されています。このパーサーを使って1+2という演算を変換してみます。

<?php

use PhpParser\ParserFactory;

$parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
$ast = $parser->parse('<?php 1+2 ?>');

出力は以下のようになります。

[
  PhpParser\Node\Stmt\Expression {
    +expr: PhpParser\Node\Expr\BinaryOp\Plus {
      +left: PhpParser\Node\Scalar\LNumber {
        +value: 1,
      },
      +right: PhpParser\Node\Scalar\LNumber {
        +value: 2,
      },
    },
  },
]

入れ子構造の結果が得られました。

ノードの操作

各ノードは子ノードへの参照をプロパティとして持っているため、以下のようにアクセスできます。

<?php 

$ast[0]->expr->left->value;    // 1

この構造を視覚的に表現すると以下のようになります。

$ast[0]
  └─ expr (Expr\BinaryOp\Plus)
      ├─ left (Scalar\LNumber)
      │   └─ value: 1
      └─ right (Scalar\LNumber)
          └─ value: 2

演算式全体のノードを根として、+演算子の子ノード、さらにその下に左右のスカラー値のノードが連なる木構造になっています。

一部のノードは自身の型に関する情報も持っており、型の妥当性チェックに利用できます。

<?php 

$ast[0]->expr->getType();        // Expr_BinaryOp_Plus
$ast[0]->expr->left->getType();  // Scalar_LNumber

より複雑な例

次に、プロパティとアクセサを持つ簡単なクラスを変換してみます。

<?php 

$code = <<<'CODE'
<?php

class Foo
{
    private int $bar;

    public function getBar(): int
    {
        return $this->bar;
    }

    public function setBar(int $value): void
    {
        $this->bar = $value;
    }
}
CODE;

$ast = $parser->parse($code);

変換結果は少し長いので中間要素を抜き出すと以下のようになります。

[
  PhpParser\Node\Stmt\Class_ {
    +name: PhpParser\Node\Identifier {
      +name: "Foo",
    },
    +stmts: [
      PhpParser\Node\Stmt\Property {},
      PhpParser\Node\Stmt\ClassMethod {},
      PhpParser\Node\Stmt\ClassMethod {},
    ],
    +attrGroups: [],
    +namespacedName: null,
    +flags: 0,
    +extends: null,
    +implements: [],
  },
]

根にクラス宣言のノードStmt\Class_があり、namestmtsに以下の子ノードが連なっています。

  • クラスの識別子 Identifier
  • プロパティ Stmt\Property
  • クラスメソッド Stmt\ClassMethod(2つ)

さらにそれぞれの要素を見ていくと以下のような構成になっています。

<?php 

$ast[0]->name->name;                          // "Foo"

$ast[0]->stmts[0]->props[0]->name->name;     // "bar"
$ast[0]->stmts[1]->name->name;               // "getBar"
$ast[0]->stmts[1]->returnType->name;         // "int"
$ast[0]->stmts[2]->params[0]->type->name;    // "int"
$ast[0] (Stmt\Class_)
  ├─ name (Identifier)
  │   └─ name: "Foo"
  └─ stmts (array)
      ├─ [0] (Stmt\Property)
      │   └─ props[0]
      │       └─ name: "bar"
      ├─ [1] (Stmt\ClassMethod)
      │   ├─ name: "getBar"
      │   └─ returnType
      │       └─ name: "int"
      └─ [2] (Stmt\ClassMethod)
          ├─ name: "setBar"
          └─ params[0]
              └─ type
                  └─ name: "int"

クラス定義のノードを根として、クラス名、プロパティ、メソッドが階層的に配置されています。各メソッドはさらに戻り値の型や引数の型情報を持っています。

PHPStanはこのようなASTを探索してコードを解析しており、カスタムルールもこのデータ構造に基づいて実装します。

カスタムルールの作成方法

実際にカスタムルールを作成する手順を見ていきます。

1. ルールクラスの作成

PHPStanが提供するPHPStan\Rules\Ruleインターフェースを実装したクラスを作成します。

<?php

declare(strict_types=1);

namespace Namespace\To\RuleFile;

use PhpParser\Node;
use PHPStan\Rules\Rule;
use PHPStan\Analyser\Scope;

class SampleCustomRule implements Rule
{
    public function getNodeType(): string {}

    public function processNode(Node $node, Scope $scope): array {}
}

2. 設定ファイルへの登録

phpstan.neonrulesブロックに作成したカスタムルールの名前空間を追加します。

rules:
    - Namespace\To\RuleFile\SampleCustomRule

これで次回の実行時から追加ルールが適用されます。

3. getNodeType()の実装

getNodeType()は、このルールがどのタイプのノードに対して適用されるかを宣言するメソッドです。

PHPStanはASTを探索する際、すべてのノードをこのメソッドで指定した型と照合し、マッチしたノードのみを後述するprocessNode()に渡します。

戻り値にはPhpParser\Nodeのサブクラスのクラス名(class-string)を指定します。

<?php 

// 演算子のノードのみを対象とする場合
public function getNodeType(): string
{
    return BinaryOp::class;
}

// メソッド呼び出しのノードのみを対象とする場合
public function getNodeType(): string
{
    return MethodCall::class;
}

例えばBinaryOp::classを指定すると、コード中の+-*/などの演算子ノードだけがprocessNode()に渡され、変数やメソッド呼び出しなどは無視されます。

4. processNode()の実装

processNode()は実際の解析処理を定義するメソッドです。getNodeType()でマッチしたノードに対して実行されます。

Nodeパラメータの活用

第1引数の$nodeは、getNodeType()で指定した型にマッチしたノードです。ノードタイプに応じた様々なプロパティを持っており、コードの詳細な情報にアクセスできます。

<?php 

// 二項演算子ノード(BinaryOp)の場合
$node->left;   // 左辺のノード
$node->right;  // 右辺のノード

// メソッド呼び出しノード(MethodCall)の場合
$node->var;    // メソッドが呼ばれるオブジェクト
$node->name;   // メソッド名
$node->args;   // 引数のリスト

// クラスノード(Class_)の場合
$node->name;       // クラス名
$node->extends;    // 継承元クラス
$node->implements; // 実装するインターフェース
$node->stmts;      // クラス内の実装(メソッドやプロパティ)

戻り値の形式

processNode()の戻り値はIdentifierRuleErrorの配列です。ルール違反を検出した場合はRuleErrorBuilderでエラーを構築し、問題がなければ空配列を返します。

<?php 

use PHPStan\Rules\RuleErrorBuilder;

/**
 * ゼロ除算の検査
 */
public function processNode(Node $node, Scope $scope): array
{
    // ノードが除算演算子かつ右辺が数値の0ならエラーを返す
    if ($node instanceof Node\Expr\BinaryOp\Div
        && $node->right instanceof Node\Scalar\LNumber
        && $node->right->value === 0) {
        return [
            RuleErrorBuilder::message('zero division error')
                ->identifier('custom.zeroDivision')
                ->build()
        ];
    }
    // 問題ない場合は空配列を返す
    return [];
}

RuleErrorBuildermessage()でエラーメッセージを設定し、identifier()でエラーの識別子を指定します。識別子はエラーの分類や抑制設定に使用されます。

Scopeの活用

第2引数の$scopeは、ノード単体では取得できない文脈の情報を提供します。

Scopeの役割

Scopeオブジェクトは、現在解析中のコードのスコープに関する情報を保持しています。具体的には以下のような情報にアクセスできます。

  • 現在のクラスやメソッドの情報
  • 変数や式の型情報
  • 名前空間の情報
  • 利用可能な関数やクラスの情報

Scopeの主な機能

1. 型情報の取得

PHPStanの型推論エンジンによって計算された、ノードの式が持つ型を取得できます。

<?php 

// 式の型を取得
$type = $scope->getType($node->var);

// 型をチェック
if ($type->isObject()->yes()) {
    $classNames = $type->getObjectClassNames();  // クラス名を取得
}

2. クラス情報の取得

現在のスコープが属するクラスの情報を取得できます。

<?php 

// 現在のクラスのリフレクション情報を取得
$classReflection = $scope->getClassReflection();
if ($classReflection !== null) {
    $className = $classReflection->getName();           // クラス名
    $parentClass = $classReflection->getParentClass();  // 親クラス
    $interfaces = $classReflection->getInterfaces();    // 実装インターフェース
}

3. メソッド情報の取得

現在のスコープが属するメソッドの情報を取得できます。

<?php 

// 現在のメソッド名を取得
$methodReflection = $scope->getFunction();
if ($methodReflection !== null) {
    $methodName = $methodReflection->getName();
}

4. 変数の型チェック

スコープ内で定義された変数の型を確認できます。

<?php 

// 変数が定義されているかチェック
if ($scope->hasVariableType('variableName')->yes()) {
    $variableType = $scope->getVariableType('variableName');
}

Scopeの実践例

メソッド呼び出し元の実装クラスを取得する例です。

<?php 

// メソッド呼び出しノードの場合
// $node: Node\Expr\MethodCall

$calledOnType = $scope->getType($node->var);
if ($calledOnType->isObject()->yes()) {
    $classNames = $calledOnType->getObjectClassNames();
    // $classNamesには実際のクラス名が入る
}

$node->varだけでは変数名しか分かりませんが、$scope->getType()を使うことで、その変数の実際の型(クラス名)を解決できます。

ノード別の実装例

ここからは、ノードごとのユースケースと実装例を紹介します。

変数と代入(Node\Expr\Variable, Node\Expr\Assign)

変数名の命名規則をチェックする例です。

<?php 

public function getNodeType(): string
{
    return Node\Expr\Variable::class;
}

public function processNode(Node $node, Scope $scope): array
{
    if (is_string($node->name) && preg_match('/^[a-z][a-zA-Z0-9]*$/', $node->name) !== 1) {
        return [
            RuleErrorBuilder::message("変数名はキャメルケースで記述してください: \${$node->name}")
                ->identifier('custom.variable.camelCase')
                ->build()
        ];
    }
    return [];
}

演算子(Node\Expr\BinaryOp)

浮動小数点数を含む演算で誤差の可能性を警告する例です。

<?php 

public function getNodeType(): string
{
    return Node\Expr\BinaryOp::class;
}

public function processNode(Node $node, Scope $scope): array
{
    if (($node instanceof BinaryOp\Plus
        || $node instanceof BinaryOp\Minus
        || $node instanceof BinaryOp\Mul
        || $node instanceof BinaryOp\Div
        || $node instanceof BinaryOp\Mod
        || $node instanceof BinaryOp\Pow
    ) && (
        $scope->getType($node->right)->isFloat()->yes()
        || $scope->getType($node->left)->isFloat()->yes()
    )) {
        return [
            RuleErrorBuilder::message('浮動小数点数を含む演算です。誤差の可能性があります')
                ->identifier('custom.float.arithmetic')
                ->build()
        ];
    }
    return [];
}

インスタンス化(Node\Expr\New_)

Singletonパターンのクラスが直接インスタンス化されていないかチェックする例です。

<?php 

public function getNodeType(): string
{
    return Node\Expr\New_::class;
}

public function processNode(Node $node, Scope $scope): array
{
    // Singletonを名前に含むクラスがnewされていないか
    if ($node->class instanceof Name) {
        // クラス名のみを取得(名前空間を除く)
        $className = $node->class->getLast();
        if (str_contains($className, 'Singleton')) {
            return [
                RuleErrorBuilder::message('このクラスのインスタンス化は禁止されています')
                    ->identifier('custom.singleton.new')
                    ->build()
            ];
        }
    }
    return [];
}

メソッド呼び出し(Node\Expr\MethodCall, Node\Expr\StaticCall)

PHPDocの@deprecatedタグが付けられたメソッドの使用を検出する例です。非推奨APIを段階的に廃止する際に有用です。

<?php 

public function getNodeType(): string
{
    return Node\Expr\MethodCall::class;
}

public function processNode(Node $node, Scope $scope): array
{
    if (!$node->name instanceof Node\Identifier) {
        return [];
    }

    $calledOnType = $scope->getType($node->var);

    if (!$calledOnType->isObject()->yes()) {
        return [];
    }

    $methodName = $node->name->toString();

    // メソッドの型情報を取得
    if ($calledOnType->hasMethod($methodName)->yes()) {
        $methodReflection = $calledOnType->getMethod($methodName, $scope);

        // PHPDocから@deprecatedタグをチェック
        $docComment = $methodReflection->getDocComment();
        if ($docComment !== null && str_contains($docComment, '@deprecated')) {
            $classNames = $calledOnType->getObjectClassNames();
            return [
                RuleErrorBuilder::message(sprintf(
                    "非推奨メソッド %s::%s() が使用されています",
                    $classNames[0],
                    $methodName,
                ))
                    ->identifier('custom.method.deprecated')
                    ->build()
            ];
        }
    }

    return [];
}

可変関数($func()のような形式)の使用を検出する例です。

<?php 

public function getNodeType(): string
{
    return Node\Expr\FuncCall::class;
}

public function processNode(Node $node, Scope $scope): array
{
    // 関数名が変数の場合(可変関数)
    if ($node->name instanceof Node\Expr\Variable) {
        return [
            RuleErrorBuilder::message('可変関数の使用は推奨されません。静的解析が困難になります')
                ->identifier('custom.function.variable')
                ->build()
        ];
    }

    return [];
}

関数呼び出し(Node\Expr\FuncCall)

危険な関数の使用を検出する例です。セキュリティ上のリスクがある関数を禁止することで、脆弱性の混入を防げます。

<?php 

public function getNodeType(): string
{
    return Node\Expr\FuncCall::class;
}

public function processNode(Node $node, Scope $scope): array
{
    if ($node->name instanceof Name) {
        $functionName = $node->name->toString();

        // セキュリティリスクのある関数リスト
        $dangerousFunctions = [
            'exec' => 'コマンドインジェクションのリスクがあります',
            'shell_exec' => 'コマンドインジェクションのリスクがあります',
            'system' => 'コマンドインジェクションのリスクがあります',
            'passthru' => 'コマンドインジェクションのリスクがあります',
        ];

        if (isset($dangerousFunctions[$functionName])) {
            return [
                RuleErrorBuilder::message(sprintf(
                    "危険な関数 %s() の使用は禁止されています: %s",
                    $functionName,
                    $dangerousFunctions[$functionName]
                ))
                    ->identifier('custom.function.dangerous')
                    ->build()
            ];
        }
    }
    return [];
}

クラス定義(Node\Stmt\Class_)

クラス名の命名規則をチェックする例です。

<?php 

public function getNodeType(): string
{
    return Node\Stmt\Class_::class;
}

public function processNode(Node $node, Scope $scope): array
{
    if ($node->name !== null) {
        $className = $node->name->toString();

        // クラス名がPascalCaseになっているかチェック
        if (preg_match('/^[A-Z][a-zA-Z0-9]*$/', $className) !== 1) {
            return [
                RuleErrorBuilder::message("クラス名はPascalCaseで記述してください: {$className}")
                    ->identifier('custom.class.pascalCase')
                    ->build()
            ];
        }
    }
    return [];
}

制御構文(Node\Stmt\If, Node\Stmt\Switch

switch(true)のようなアンチパターンを検出する例です。

<?php 

public function getNodeType(): string
{
    return Node\Stmt\Switch_::class;
}

public function processNode(Node $node, Scope $scope): array
{
    // switch(true)のようなcaseでブール式を評価させるケースの検出
    if ($node->cond instanceof Node\Expr\ConstFetch
        && $node->cond->name->toString() === 'true') {
        return [
            RuleErrorBuilder::message('switch(true)の使用は推奨されません。if-elseifを使用してください')
                ->identifier('custom.switch.true')
                ->build()
        ];
    }
    return [];
}

例外処理(Node\Stmt\TryCatch)

空のcatchブロックを検出する例です。

<?php 

public function getNodeType(): string
{
    return Node\Stmt\TryCatch::class;
}

public function processNode(Node $node, Scope $scope): array
{
    foreach ($node->catches as $catch) {
        if (count($catch->stmts) === 0) {
            return [
                RuleErrorBuilder::message('空のcatchブロックは避けてください。最低限ログ出力を行ってください')
                    ->identifier('custom.catch.empty')
                    ->build()
            ];
        }
    }
    return [];
}

カスタムルール実装のポイント

ここまでの実装例を踏まえて、ポイントをまとめます。

1. ノードタイプの選択

検出したいパターンに最も近いノードタイプをgetNodeType()で指定します。メソッド呼び出しならMethodCall、クラス定義ならClass_というように、対象を絞ることで不要なノードの処理をスキップできます。

2. Scopeを活用した型解決

ノード単体では変数名やメソッド名しか分かりませんが、Scopeを使うことで実際の型情報を解決でき、より高度なチェックが可能になります。

3. 段階的な条件チェック

instanceofによる型チェックと早期リターンを活用することで、コードの可読性と安全性を保てます。

4. 分かりやすいエラーメッセージ

エラーメッセージには、何が問題でどうすれば良いかを明確に記載することで、開発者が素早く修正できます。

これらの実装例はプロジェクトの要件に合わせてカスタマイズできます。

Rectorへの応用

PHPStanがコードの問題を検出するツールであるのに対し、Rectorはコードを自動的に書き換えるリファクタリングツールです。RectorもPHPStanと同様にASTを使用してコードを解析・変更するため、カスタムルールで学んだASTの知識をそのまま活用できます。

Rectorの既定ルールの紹介

Rectorには、PHPのバージョンアップやコードの近代化に役立つ既定ルールが多数用意されています。いくつか紹介します。

1. PHPバージョンアップ対応

PHP 7.4から8.0への移行時に、nullセーフ演算子を自動で適用するNullsafeOperatorRectorです。

<?php 

// rector.php
use Rector\Php80\Rector\If_\NullsafeOperatorRector;

$rectorConfig->rule(NullsafeOperatorRector::class);
<?php 

// Before
$country = null;
if ($session !== null) {
    $user = $session->user;
    if ($user !== null) {
        $address = $user->getAddress();
        if ($address !== null) {
            $country = $address->country;
        }
    }
}

// After (Rector適用後)
$country = $session?->user?->getAddress()?->country;

2. 条件文のリファクタリング

三項演算子を簡潔なnull合体演算子に置き換えるTernaryToNullCoalescingRectorです。

<?php 

// rector.php
use Rector\Php70\Rector\Ternary\TernaryToNullCoalescingRector;

$rectorConfig->rule(TernaryToNullCoalescingRector::class);
<?php 

// Before
$name = isset($user['name']) ? $user['name'] : 'Guest';

// After (Rector適用後)
$name = $user['name'] ?? 'Guest';

3. declare文の自動追加

全てのPHPファイルにdeclare(strict_types=1)を自動で追加するDeclareStrictTypesRectorです。

<?php 

// rector.php
use Rector\TypeDeclaration\Rector\StmtsAwareInterface\DeclareStrictTypesRector;

$rectorConfig->rule(DeclareStrictTypesRector::class);
<?php

// Before
namespace App;

// After (Rector適用後)
<?php

declare(strict_types=1);

namespace App;

4. 名前空間の整理

完全修飾名(FQCN)を使っている箇所をuse文に集約する機能です。これは個別のルールではなく、Rectorの設定メソッド importNames() で有効化します。

<?php 

// rector.php
$rectorConfig->importNames();
<?php 

// Before
public function process(): \App\Service\UserService
{
    return new \App\Service\UserService();
}

// After (Rector適用後)
use App\Service\UserService;

public function process(): UserService
{
    return new UserService();
}

これらの既定ルールを使うだけでも、コードの品質向上や保守性の改善に貢献します。

Rectorのカスタムルール例

既定ルールに加えて、プロジェクト固有のルールも作成できます。古いメソッドを新しいメソッドに自動置換するルールの例です。

<?php 

use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Identifier;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;

final class RenameOldMethodRector extends AbstractRector
{
    public function getRuleDefinition(): RuleDefinition
    {
        return new RuleDefinition('Rename oldMethod to newMethod', []);
    }

    public function getNodeTypes(): array
    {
        return [MethodCall::class];
    }

    public function refactor(Node $node): ?Node
    {
        if (!$node instanceof MethodCall) {
            return null;
        }

        if (!$this->isName($node->name, 'oldMethod')) {
            return null;
        }

        $node->name = new Identifier('newMethod');
        return $node;
    }
}

PHPStanのprocessNode()が問題を検出してエラーメッセージを返すのに対し、Rectorのrefactor()はノードを書き換えて返すことで、実際にコードを変更します。

まとめ

PHPStanのカスタムルールは、標準のルールだけではカバーできない、プロジェクト固有の検査要件に対応できる強力な機能です。

カスタムルールの有用性

本記事では、ASTの基本的な概念からカスタムルールの実践的な作成方法まで解説しました。

ASTの仕組みを理解すれば、変数の命名規則チェックから複雑な依存関係の検証まで、プロジェクトの要件に合わせた多様なルールを実装できます。NodeScopeを組み合わせることで、ノードの構造だけでなく型情報やクラス情報を用いた高度な解析も可能です。

実務での活用ポイント

カスタムルールを実務で活用する際のポイントは以下の通りです。

1. 段階的な導入

最初からすべての要件を満たす必要はありません。まずは命名規則チェックなどの簡単なルールから始めて、徐々に拡張していくのが効果的です。

2. チームでのルール共有

カスタムルールはチーム全体で合意を取ることが重要です。ルールの意図や背景をドキュメント化し、機械的にチェックする部分と人間が判断する部分を明確に分けることで、レビュー時間を短縮しつつ品質を保てます。

3. 継続的な改善

プロジェクトの成長に合わせてルールも進化させていく必要があります。誤検知が多いルールは改善し、新たな課題が見つかれば新しいルールを追加することで、コードベースの品質を継続的に向上できます。

発展的な活用

カスタムルールで学んだASTの知識は、Rectorなど他のPHPツールにも応用できます。静的解析とコード変換を組み合わせることで、「問題を検出する」だけでなく「問題を自動で修正する」という、一歩進んだコード品質管理が実現できます。

最後に

ぜひプロジェクトの要件に合わせたカスタムルールを作成し、コードベースの品質向上に役立ててください。

参考資料



ColoplTechについて

コロプラでは、勉強会やブログを通じてエンジニアの方々に役立つ技術や取り組みを幅広く発信していきます。
connpass および X で情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。