🙌

キミにも作れるPHPStan拡張

2024/04/13に公開

こんにちは! PHPStanを活用していらっしゃいますでしょうか。

PHPStanは今日では言わずと知れた静的解析ツールですが、プラグイン機構を備えており、型付けのためのさまざまな機能を提供しています。PHPStanはできるだけコードを書かずPHPDocによる記述だけで多くのケースには型をつけられるようになっていますが、残念ながら本稿執筆時点のPHPStan 1.10系ではPHPDocだけですべてのニーズを満たすことはできないため、より深くPHPStanを利用するにはプラグインの理解が必要になります。

プラグイン機構は外部パッケージによる拡張だけでなく、PHPStan本体に組み込まれている標準関数への型付けなどにも使われています。つまり、PHPStanがどのような原理で解析対象のコードに型をつけているのかを知るにはプラグインAPIへの理解が必要になります。本稿ではPHPStanの拡張APIについて簡単に説明し、実用的に使える拡張プラグインを作れるところまでまとめます。

PHPStan拡張の種類

PHPStanは1.10.x系列の段階で以下のような種類の拡張が用意されています。

  • カスタムルール (Custom Rules)
  • コレクター (Collectors)
  • エラーフォーマッター (Error Formatters)
  • クラスリフレクション拡張 (Class Reflection Extensions)
  • 動的戻り値拡張 (Dynamic Return Type Extensions)
  • 動的スロー型拡張 (Dynamic Throw Type Extensions)
  • 型指定拡張 (Type-Specifying Extensions)
  • カスタムPHPDoc型 (Custom PHPStan Types)
  • 常に読み書き可能なプロパティ (Always-Read and Written Properties)
  • 常に使用されるクラス定数 (Always-Used Class Constants)
  • 許可されたサブタイプ (Allowed Subtypes)

この中でもよく登場するのが「カスタムルール」と「動的戻り値拡張」のふたつです。特に後半のものは名前から目的が読み取りにくいかもしれませんが、実際それほど難しい仕様でもないので各自公式のマニュアルを読んで学んでいただくのがよいでしょう。

「よく使われる」とはどういうことでしょうか。実際にPHPStanのソースコードに含まれる拡張クラスを数えてみると、PHPStan 1.10.55に標準で組み込まれている拡張クラスはカスタムルールが265個、動的戻り値拡張が123個です。PHPStanとしての存在感から言っても実用的な必要性から見ても、この2種類の拡張を押さえておけばPHPStanを使いこなす上でかなり役立つでしょう。個人的な経験から言ってもこれまで書いてきた拡張はカスタムルールと動的戻り値拡張がほとんどです。

先述した通りPHPStanに組み込まれている機能とユーザーによる拡張プラグインは基本的にどちらも同じ方法と同じ道具立てで記述することになります。自作の拡張を書く際にも、PHPStan本体に既に同梱されている拡張がどのように処理しているのかを見るのが一番の近道でしょう。

PHPStanのソースコードにアクセスする

このような理由により、PHPStan拡張を記述するにはまず、PHPStanに組み込まれている拡張がどのように記述されているのかを読み解くことが重要です。PHPStanのソースコードはGitHubの https://github.com/phpstan/phpstan-src にあります。

https://github.com/phpstan/phpstan-src

本稿執筆時点でのデフォルトブランチは1.11.xですが、活発に開発されているのは1.10.xであることに注意してください。機能追加のPull Requestも基本的に1.10.xに送ることになりますが、メインの開発ブランチは随時変化するので、その時々の状況に従ってください。ソースコードはGitHub上で閲覧しても構いませんが、おそらくローカルにgit cloneしてPhpStormのようなIDEで開けるようにした方が便利でしょう。

先述の通りPHPStanの開発は https://github.com/phpstan/phpstan-src で行われていますが、composer installでPHPStanをインストールする際はPackagistでは https://github.com/phpstan/phpstan という別のリポジトリで管理されています。phpstan-srcリポジトリの開発にはPHP 8.1以上が必要ですが、srcがつかない方のリポジトリではPHP 7.3互換にダウングレードした上でPharアーカイブにコンパイルされて管理しています。

PHPStanに同梱されている既存拡張を読む

まず、動的戻り値拡張を見ていきましょう。戻り値拡張に限らず、拡張プラグインはPHPStanで定義済みのインターフェイスを実装(implement)することで実現します。

まとめて「戻り値」と読んでいますが、PHPの関数呼び出しのようなものにはの拡張は型付けの対象が関数呼び出しか、メソッド呼び出し(インスタンスメソッド)か、静的メソッド呼び出しかによってそれぞれDynamicFunctionReturnTypeExtension / DynamicMethodReturnTypeExtension / DynamicStaticMethodReturnTypeExtensionのどれを実装するかが変わります。

はじめにDynamicFunctionReturnTypeExtensionを見てみましょう。

以下のソースコードは https://github.com/phpstan/phpstan-src/tree/1.10.56 より引用し、紙幅の都合上改行位置を調整し、実行に影響ない use functionuse const を省略しています。

<?php declare(strict_types = 1);

namespace PHPStan\Type;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;

/**
 * This is the interface dynamic return type extensions implement for functions.
 *
 * To register it in the configuration file use the `phpstan.broker.dynamicFunctionReturnTypeExtension` service tag:
 *
 * ```
 * services:
 * 	-
 *		class: App\PHPStan\MyExtension
 *		tags:
 *			- phpstan.broker.dynamicFunctionReturnTypeExtension
 * ```
 *
 * Learn more: https://phpstan.org/developing-extensions/dynamic-return-type-extensions
 *
 * @api
 */
interface DynamicFunctionReturnTypeExtension
{
	public function isFunctionSupported(FunctionReflection $functionReflection): bool;
	public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type;
}

拡張クラスはこのインターフェイスに登録されているメソッドを実装する必要があるということです。isFunctionSupportedは、この拡張クラスがその関数呼び出しを対象としているかをbool値で返します。getTypeFromFunctionCallはその拡張がどのような型を返すかを、PHPStanの型オブジェクト(PHPStan\Type\Type)を実装したクラス)を返します。

その前に、そもそもこの種類の拡張がなぜ必要になるのかということを確認しましょう。PHPマニュアルにはcount()関数の戻り値は以下のように書かれています。

count(Countable|array $value, int $mode = COUNT_NORMAL): int

「引数にCountablearray型の$valueint型の$modeを受け取って、intを返す」ということです。このような型情報は、PHPStanでは https://github.com/phpstan/phpstan-src/blob/1.10.55/resources/functionMap.php#L1368 で管理されていますが、PHPマニュアルに書かれている通りの型が記載されているわけではありません。

では、count()関数が本当はどのような値を返しうるのかを確認してみましょう。

  • count([]) ⇒ 常に int(0) を返す
  • count(['a']) ⇒ 常に int(1) を返す
  • count([1, 2]) ⇒ 常に int(2) を返す
  • count($array)$arrayが配列であれば、0以上のintを返す
  • count($object)$objectCountableを実装したオブジェクトであれば、0以上のintを返す

人間様がソースコードを見ればこのような結果になることは見ればわかりますが、PHPが持つ本来の型システムには型宣言に記述できるintという型名と、実行時のオブジェクトが持つ型タグでしか表現できません。ところがPHPStanはPHPの持つ型システムのスーパーセットと言ってよい型情報を持っています。

PHPStanの組み込み型の一部を、継承ツリーとして見てみましょう。

  • PHPStan\Type\Type
    • PHPStan\Type\IntegerType
      • PHPStan\Type\IntegerRangeType
      • PHPStan\Type\Constant\ConstantIntegerType
      • PHPStan\Type\Generic\TemplateIntegerType
    • PHPStan\Type\ArrayType
      • PHPStan\Type\Constant\ConstantArrayType
      • PHPStan\Type\Generic\TemplateArrayType
    • PHPStan\Type\Accessory\AccessoryType
      • PHPStan\Type\Accessory\NonEmptyArrayType
      • PHPStan\Type\Accessory\AccessoryArrayListType
      • PHPStan\Type\Accessory\OversizedArrayType

IntegerTypeはそのままPHPのintに対応する型ですが、ConstantIntegerTypeは定数やリテラルで書かれた数や、そのような計算の結果など実行時に変わらない整数の値そのものを扱える型です。ソースコード上に 1 + 2 と書かれていたとすると、その結果は new ConstantIntegerType(3) として扱えてしまうということです。IntegerRangeTypeintの範囲に対応する型、つまり実行時にとりうる値域がその範囲に収まる整数ということです。PHPDocでは int<1, 3> のように記述できますが、ユニオン型で表すと 1|2|3 と等価になります。PHPDocの int<min, -1> はPHPで整数として扱える下限の値から-1まで、つまり負の整数を表します。逆に int<1, max> は正の整数ということです。IntegerRangeTypeはそのような型を扱えます。

ArrarTypeも同じくPHPのarrayに対応する型を表します。ですが、IntegerRangeTypeintの範囲に対応する型、つまり実行時にとりうる値域がその範囲に収まる整数ということです。ConstantArrayTypeはPHPDocではarray-shapes記法で array{key1: string, key2?: int} と書けるもののことです。

AccessoryTypeは上記のような型と交叉型(intersection types)で組み合わせることで、付加的な情報を与えることができる型です。たとえば、ArrayTypeNonEmptyArrayTypeを組み合わせると、長さが1以上(0ではない)配列だという型を表します。

改めて、funcctionMap.phpの型情報を見ましょう。

'count' => ['0|positive-int', 'var'=>'Countable|array', 'mode='=>'int'],

この形式の型情報はPhanという静的解析ツールに由来するのですが、先頭の要素が関数の戻り値型、次以降の要素がパラメータ(引数)の型です。考えてみると、長さが1の配列、長さが0の配列というものは存在しますが、長さがマイナスの配列というのは現実的に想像しにくいところがあります。PHPでも、もちろんそのような配列を作成することはありません。なので、count()の結果がマイナスになるということは、この関数を利用するひとはまったく考慮する必要がないことです。

詳細な型をつけるということは、利用者に余計なことに気を回させないということです。つまり、コードレビューで「キミぃ、count()の結果がマイナスになったときのことを考慮してないんじゃないのかね?」などと意味不明なことを言わなくてもいいということです。PHPのような動的型の言語でもpositive-intのような型を付けるということにはこのような効能があるということです。

PHPStanの型についての知識を得て、DynamicFunctionReturnTypeExtensionもインターフェイスとして思いのほか簡潔だということがわかったところで、実際のソースコードを見ていきましょう。

PHP標準関数への型付けは、count()関数に対して PHPStan\Type\Php\CountFunctionReturnTypeExtension というクラスが対応しています。

<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\Type;

class CountFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{
	public function isFunctionSupported(FunctionReflection $functionReflection): bool
	{
		return in_array($functionReflection->getName(), ['sizeof', 'count'], true);
	}

	public function getTypeFromFunctionCall(
		FunctionReflection $functionReflection,
		FuncCall $functionCall,
		Scope $scope,
	): ?Type
	{
		if (count($functionCall->getArgs()) < 1) {
			return null;
		}

		if (count($functionCall->getArgs()) > 1) {
			$mode = $scope->getType($functionCall->getArgs()[1]->value);
			if ($mode->isSuperTypeOf(new ConstantIntegerType(COUNT_RECURSIVE))->yes()) {
				return null;
			}
		}

		return $scope->getType($functionCall->getArgs()[0]->value)->getArraySize();
	}
}

まず最初のisFunctionSupported()で、関数名が'sizeof''count'のどちらかであればtrueを返します。渡されるオブジェクトはFunctionReflection、つまり関数の情報を持ったリフレクションとよばれるオブジェクトです。次にgetTypeFromFunctionCall()を見ましょう。ここで受け取るのは同じリフレクション、関数呼び出しに対応するPHP-ParserのFuncCallオブジェクト、そしてPHPStanの変数スコープに対応するScopeオブジェクトです。

最初の分岐、count($functionCall->getArgs())では関数呼び出しに引数が0個ならnullを返しています。DynamicFunctionReturnTypeExtensionnullを返すと、functionMapで定義された型をそのまま反映します。引数が0個というのは実際にはエラーになるのですが、引数の数が合わないということは別のルールが検証してくれるのでこれで十分なのです。

続いて、 count($functionCall->getArgs()) > 1 です。これは引数が2個、つまり省略可能なオプションが渡されたときのコードです。これが COUNT_RECURSIVE というmodeだったときにも、処理を打ち切ってデフォルトの戻り値を返します。

最後のreturnでは、パラメータから取得したgetArraySize()という値をそのまま返しています。これは全てのTypeオブジェクトが持っているgetArraySize()メソッドに処理を委ねます。Typeオブジェクトが持っている配列のサイズとは何でしょうか。先述の通り普通の配列の長さがマイナスになりうることはないので int<0, max>ConstantArrayTypeなら array{0: string} ならば 1、そして array{a?: bool, b?: bool, c?: bool} ならばキーのあるなしの組み合わせによって0個、1個、2個のどれか、つまり int<0, 3> となります。PHPStanのTypeインターフェイスではこのような型に特化した計算をそれぞれの継承クラスごとに定義するのではなく、すべてのTypeオブジェクトのメソッドに持たせているので、呼び出し側でそのTypeオブジェクトが何者なのかということを誰何する必要はなく、単にメソッドを呼ぶだけでいいのです。名前に反して、Countableを実装したオブジェクトもgetArraySize()に有効な値を返します。PHPではcount()関数はPythonのlen関数などと異なり、対応しているのは配列とCountableを実装したオブジェクトだけです。対応していないクラスではErrorTypeを返します。これは最終的なこの関数の戻り値になります。count('hoge')の結果は正常な値ではなく、エラーになるということです。

さらに別のパターンも見てみましょう。

<?php declare(strict_types = 1);

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr\BinaryOp\Pow;
use PhpParser\Node\Expr\FuncCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\FunctionReflection;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\Type;

class PowFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{

	public function isFunctionSupported(FunctionReflection $functionReflection): bool
	{
		return $functionReflection->getName() === 'pow';
	}

	public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
	{
		if (count($functionCall->getArgs()) < 2) {
			return null;
		}

		return $scope->getType(new Pow($functionCall->getArgs()[0]->value, $functionCall->getArgs()[1]->value));
	}

}

こちらのパターンはTypeオブジェクト自身ではなく、Scope::getType()オブジェクトに演算させています。引数のnew Pow()に注目してください。これはPHP-ParserのExprノード、つまり式をオペランドにとることができます。これもExtensionのレベルで処理できます。

以下はもう少し複雑な例です。これはPHP 8.3で追加されたstr_increment()str_decrement()をサポートするものです。

<?php declare(strict_types = 1);
// 中略
class StrIncrementDecrementFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{
	public function isFunctionSupported(FunctionReflection $functionReflection): bool
	{
		return in_array($functionReflection->getName(), ['str_increment', 'str_decrement'], true);
	}

	public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type
	{
		$fnName = $functionReflection->getName();
		$args = $functionCall->getArgs();

		if (count($args) !== 1) {
			return null;
		}

		$argType = $scope->getType($args[0]->value);
		if (count($argType->getConstantScalarValues()) === 0) {
			return null;
		}

		$types = [];
		foreach ($argType->getConstantScalarValues() as $value) {
			if (!(is_string($value) || is_int($value) || is_float($value))) {
				continue;
			}
			$string = (string) $value;

			if (preg_match('/\A(?:0|[1-9A-Za-z][0-9A-Za-z]*)+\z/', $string) < 1) {
				continue;
			}

			$result = null;
			if ($fnName === 'str_increment') {
				$result = $this->increment($string);
			} elseif ($fnName === 'str_decrement') {
				$result = $this->decrement($string);
			}

			if ($result === null) {
				continue;
			}

			$types[] = new ConstantStringType($result);
		}

		return count($types) === 0
			? new ErrorType()
			: TypeCombinator::union(...$types);
	}

	private function increment(string $s): string { /* snip */ }
	private function decrement(string $s): ?string { /* snip */ }
}

これは$argType->getConstantScalarValues()でパラメータからスカラー定数値を取り出して、$this->increment()$this->decrement()は標準関数のstr_increment()str_decrement()と同等の機能をPHPで再実装したものです。ここでは演算結果のどれかというユニオン型に構築して返します。PHPStanではTypeCombinatorクラスで型演算を支援するヘルパーメソッドが提供されており、TypeCombinator::union(...$types)非常によく見るイディオムです

簡単なルールを実装する

このまま説明したいことは尽きませんが時間と紙面のスペースが限界に近付いてきました。カスタムルールについても見ていきましょう。これがルールを実装したクラスが満たすべきインターフェイスの定義です。

<?php declare(strict_types = 1);

namespace PHPStan\Rules;

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

/**
 * This is the interface custom rules implement. To register it in the configuration file
 * use the `phpstan.rules.rule` service tag:
 *
 * ```
 * services:
 * 	-
 *		class: App\MyRule
 *		tags:
 *			- phpstan.rules.rule
 * ```
 *
 * Learn more: https://phpstan.org/developing-extensions/rules
 *
 * @api
 * @phpstan-template TNodeType of Node
 */
interface Rule
{
	/**
	 * @phpstan-return class-string<TNodeType>
	 */
	public function getNodeType(): string;

	/**
	 * @phpstan-param TNodeType $node
	 * @return (string|RuleError)[] errors
	 */
	public function processNode(Node $node, Scope $scope): array;
}

最初のメソッドgetNodeType()は、ルールが対象とするPHP-Parserの構文の種類を返します。class-string<TNodeType>と書かれているように、ここで返すのはただのクラスではなくPHP-ParserのNodeを継承したクラス名の文字列です。

関数呼び出しに対するルールであればPhpParser\Node\Expr\FuncCallに、クラスであればPhpParser\Node\Stmt\Class_を対象にします。注意が必要なのは、PHP-Parserのレベルではreturn文はPhpParser\Node\Stmt\Return_という一種類ですが、PHPStanではPHPStan\Node\MethodReturnStatementsNodeなどメソッド・関数・クロージャといった種類ごとに細分化されます。

次のprocessNode(Node $node, Scope $scope)は実際にルールを処理するメソッドです。PHP-Parserの構文木のノードとPHPStanの変数スコープオブジェクトを受け取って、ソースコードの構文木と変数スコープに沿ったルールを記述できます。非常に細かなコンテキスト(たとえば、if文の中)ごとにスコープを制御できるのはPHPStanの非常に強い特徴といえるでしょう。戻り値は文字列またはRuleErrorクラスのインスタンスが含まれる配列です。

以下の拡張は、 https://github.com/zonuexe/tadsan-phpstan-rules として公開しているPHPStan拡張です。PHPStanの拡張はComposerパッケージとして作成して配布するのが簡単です。ただし、特定のリポジトリ専用のPHPStan拡張であれば(つまり他のリポジトリで再利用されることがなければ)単にautoload-devでオートロードできれば十分です。

さて、改めて今回作成するルールはソースコードに"\n"と書くべきところを'\n'と書いてしまい意図通りに改行にならない(バックスラッシュとして展開されてしまう)ものを経験則的に検出するルールです。

ここでの経験則とはどういうことかというと、プログラム中に'\n'と書くことは本来は間違いでもなんでもありませんが、PHPへの知識が少ないメンバーが書いた場合多くは意図したものではなく、単に書き間違いであるのでプログラムミスの可能性が高いとみなして警告を出そうということです。

<?php declare(strict_types = 1);

namespace zonuexe\PHPStan;

use PhpParser\Node;
use PhpParser\Node\Scalar\String_;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;

/**
 * @implements Rule<String_>
 */
class ExcessiveEscapeRule implements Rule
{
    private const EXCESSIVE_ESCAPE_CHARACTERS = [
        '\n' => '改行(LF)',
        '\r' => '改行(CR)',
        '\t' => '水平タブ(HT)',
        '\v' => '垂直タブ(VT)',
        '\e' => 'エスケープ(ESC)',
        '\f' => 'フォームフィード(FF)',
    ];

    public function getNodeType(): string
    {
        return String_::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        $stringKind = $node->getAttributes()['kind'] ?? null;
        if (!in_array($stringKind, [String_::KIND_SINGLE_QUOTED, String_::KIND_NOWDOC], true)) {
            return [];
        }

        if (preg_match('/\A([\'\|@#]).+\1[imsxADSUXJun]*\z/', $node->value)) {
            return [];
        }

        $errors = [];
        foreach (self::EXCESSIVE_ESCAPE_CHARACTERS as $char => $description) {
            if (str_contains($node->value, $char)) {
                $errors[] = RuleErrorBuilder::message(sprintf(
                    '%s内の\'%s\'は%sに展開されません。エスケープせずに書くか、""を使ってください。',
                    match ($stringKind) {
                        String_::KIND_SINGLE_QUOTED => 'シングルクォート',
                        String_::KIND_NOWDOC => 'NowDoc',
                    },
                    $char,
                    $description,
                ))->build();
            }
        }

        return $errors;
    }
}

この拡張は @implements Rule<String_> と書いてあるように、PHP-Parserの文字列リテラルを対象とします。この記述はジェネリクスとして機能し、getNodeType()で返しているString_::classと対応しています。これによりpublic function processNode(Node $node, Scope $scope)の実装内では、型宣言で定義されているNodeよりも狭いPhpParser\Node\Scalar\String_として最初から認識できています。PHP-Parserの文字列リテラルはシングルクォート文字列、ダブルクォート文字列、ヒアドキュメント(heredoc)、ナウドック(nowdoc)の4種類があります。このうち \n と書いても改行コードに展開されないのはシングルクォート文字列ナウドックです。これらの文字列の種類はクラスではなく、クラス内のkindという属性で保持されているので、このようにin_array()でチェックしています。

次の if (preg_match('/\A([\'\|@#]).+\1[imsxADSUXJun]*\z/', $node->value)) ですが、これはPCRE正規表現(preg_match()preg_replace()関数で使われる正規表現の規則)でよく使われるパターンをやはり経験則的に検出しようとするものです。PCRE正規表現はPHP文字列のエスケープとは別に \n を解釈してくれるので、シングルクォートに書いても何の問題もありません。つまり preg_match('/\n/')preg_match("/\n/") は文字列としては別物なのですが、PCRE関数の実行上は同く動作します。PCRE正規表現としてはもっと幅広い仕様の文字列を受け入れるのですが、よく使われるパターン(というよりは、自分がよく書くパターン)はおよそこれで解釈できるでしょう。PCRE正規表現パターンについて深く騙りたいことは多いのですが、長くなるので今回はここまでにしておきます。これで検出できないパターンはデリミタに括弧を使う場合です。

ノードからの戻り値は文字列でもよいのですが、RuleErrorBuilder::message()を使うことでオブジェクトとして返すことができるようになります。PHPStan 1.11.x以降ではerror identifierという仕組みが入る予定なので、これによってコード中でエラーを識別して、特定の種類のみ適切にignoreしやすくなる予定なので積極的に使うとよいでしょう。

拡張をPHPStanにマージしてもらいたい場合や世界中のユーザーに積極的に使ってもらいたい場合はエラーメッセージも英語で出すことが望ましいですが。自分専用であったり、公用語が日本語の組織でプライベートなPHPStanルールであれば、このように日本語を使っても差し支えないでしょう。

プロジェクトからこのルールを有効化するには、PHPStanの設定ファイル(phpstan.neon)に以下のように書きます。

rules:
    - zonuexe\PHPStan\ExcessiveEscapeRule

ただし、この記法はRuleクラスにコンストラクタがないか、PHPStan側で追加設定なしに依存関係をオートワイヤリングできる場合に限ります。コンストラクタでカスタマイズ可能にしたい場合などはservicesとして記述させる必要があります。PHPStanが提供するDIコンテナの概念やservicesの登録方法についても説明したいのですが、紙幅が足りないため公式マニュアルのDependency Injection & Configurationをお読みください。PHPStanに組み込まれている拡張も同じ方法で登録され、依存関係の解決とオートワイヤリングされています。

https://phpstan.org/developing-extensions/dependency-injection-configuration

またはPHPStan Extension Installerを利用している場合は、Composerで拡張を読み込んだ時点で透過的に有効化させることもできます。利用者側が設定する場合は個別に有効化する場合は拡張パッケージが内包するルールの有効・無効を取捨選択できるのに対して、本稿執筆時点での Extension Installer 1.3.1は一括での有効化になってしまいます。(パッケージ単位で無効化することはできますが、そのパッケージ側で指定されている設定ファイルから有効化される拡張は個別選択できないということです)

利用者側が一括に有効化しない場合は、パッケージアップデート時に便利な拡張やルールが追加されたとしても変更内容などを確認して明示的に設定を追加しない限りは有効化されないというトレードオフがあります。また、ユーザーの設定値が必要な拡張などは透過的に有効化することはできないので、利用者に設定の記述方法を案内しなければなりません。

Discussion