TL;DR: エージェント向けにルールを書いても AI は非決定的なので漏れが出るかも。 プロジェクト固有の PHPStan / PHPCS カスタムルールで機械的にチェックしよう。 カスタムルールの作成は AI エージェントにたたき台を作らせることで、手早く実装できるよ。
CLAUDE.md だけでは足りない
AI エージェント(Claude Code 等)を使っている方なら、プロジェクトのルールを CLAUDE.md やルールファイルに書いて制約をかけていると思います。たとえばこんな感じ:
- コード中で旧名称の『コース』は使わず『プラン』に統一すること
DateTime::createFromTimestamp()は必ず第2引数にタイムゾーンを渡すこと- コアドメインからインフラ層を直接呼ばないこと
これでエージェントはルールを意識してコードを書いてくれます。でも、AI は非決定的です。同じ指示を出しても毎回同じ出力になるとは限りません。コンテキストが長くなったり、複雑なリファクタリングの途中だったりすると、ルールファイルに書いてあっても違反するコードを生成することがあります。
人間だって忘れるし、AI も漏らす。じゃあどうするか。
静的解析ルールで機械的にチェックすればいい
PHPStan や PHP_CodeSniffer(PHPCS)の標準ルールセットは強力ですが、プロジェクト固有の制約まではカバーしてくれません。なので、プロジェクト独自のカスタムルールを作る必要があります。
でも、カスタムルールを書くのって面倒くさい。 PHPCS の Sniff インターフェースとか、PHPStan の Rule インターフェースとか、トークン操作とか AST ノードの扱いとか、普段触らない API を調べるだけで心が折れる。 ドキュメント読みたくないでござる。 無理。
と、思っていました———
AI エージェントに投げる
ここで AI エージェントの出番です。Claude Code に「こういうルール作って」と伝えるだけで、Sniff や Rule のボイラープレートからテストまで生成してくれます。
たとえばこんなプロンプト:
PHPCS のカスタム Sniff を作って。
- コード中に「コース」という単語が含まれていたらエラーにする
- コメントや DocBlock 内は除外してもよい
- テストも書いて
PHPStan のルールも同様に:
PHPStan のカスタムルールを作って。
- App\Domain\*\Core 名前空間のクラスは、同じドメインとPHP組み込みクラス以外に依存してはいけない
- 許可リストは neon ファイルで設定できるようにして
エージェントは PHPCS や PHPStan の API をそこそこ理解しているので、トークンの扱い方や AST ノードの走査方法を一から調べる必要がなくなります。 人間は 何を禁止したいか を伝えて、生成されたコードをレビュー・調整すればいいだけです。
便利な世の中になったもんだ。
ディレクトリ構成と名前空間
カスタムルールを作る前に、ディレクトリ構成を整理しておきましょう。PHPCS と PHPStan でそれぞれルールの配置方法が異なります。
PHPCS カスタム Sniff の構成
PHPCS の Sniff は コーディング規約名をディレクトリ名にする のがルールです。AppSniff という規約名なら、以下のような構成になります:
project-root/
├── php-rules/
│ └── AppSniff/
│ ├── ruleset.xml # ルールセット定義
│ └── Sniffs/
│ ├── ForbiddenWord/
│ │ ├── AbstractForbiddenWordSniff.php
│ │ ├── ForbiddenWordCourseSniff.php
│ │ └── ForbiddenWordTreatmentMenuSniff.php
│ └── DateTime/
│ └── DateTimeCreateFromTimestampSniff.php
├── composer.json
└── phpcs.xml # プロジェクトの PHPCS 設定
PHPCS の Sniff クラスは、ディレクトリ構成がそのまま名前空間になります:
- 規約名:
AppSniff - 名前空間:
AppSniff\Sniffs\ForbiddenWord - ファイルパス:
php-rules/AppSniff/Sniffs/ForbiddenWord/
composer.json でオートロードを設定します:
{
"autoload-dev": {
"psr-4": {
"AppSniff\\": "php-rules/AppSniff/"
}
}
}
そしてプロジェクトの phpcs.xml にカスタム規約のパスを登録します:
<ruleset name="Project">
<!-- カスタム規約のパスを追加 -->
<config name="installed_paths" value="php-rules/AppSniff" />
<!-- カスタム規約を適用 -->
<rule ref="AppSniff" />
</ruleset>
PHPStan カスタムルールの構成
PHPStan のカスタムルールは通常の PHP クラスとして配置し、neon ファイルで登録します:
project-root/
├── php-rules/
│ └── phpstan/
│ ├── rules/
│ │ └── Domain/
│ │ └── ForbiddenCoreDomainDependencyRule.php
│ └── extension.neon # カスタムルールの定義
├── composer.json
└── phpstan.neon # プロジェクトの PHPStan 設定
名前空間は自由に決められますが、プロジェクトの名前空間配下に置くのが自然です:
{
"autoload-dev": {
"psr-4": {
"App\\PHPStan\\": "php-rules/phpstan/"
}
}
}
phpstan.neon からカスタムルールの neon ファイルを読み込みます:
includes:
- php-rules/phpstan/extension.neon
PHPCS は命名規約がガチガチ(ディレクトリ名=規約名、クラス名は *Sniff 必須)ですが、PHPStan は自由度が高いです。
この辺の「暗黙のルール」も AI エージェントに任せると勝手にやってくれるので楽ですね。
実例: PHPCS カスタム Sniff
実際にプロジェクトで運用しているカスタム Sniff を紹介します。
禁止ワード検出
ドメイン用語のリネームが発生したとき、旧名称がコードに残っていると混乱の元になります。そこで、禁止ワードを検出する Sniff を作りました。
まず基底クラス:
<?php
declare(strict_types=1);
namespace AppSniff\Sniffs\ForbiddenWord;
use PHP_CodeSniffer\Files\File;
use PHP_CodeSniffer\Sniffs\Sniff;
/**
* 禁止ワードを検出するスニファ
*/
abstract class AbstractForbiddenWordSniff implements Sniff
{
/** 禁止される単語 */
protected string $forbiddenWord = '';
/** コメント内の使用を許可するかどうか */
protected bool $allowInComments = false;
public function register(): array
{
return [
T_STRING,
T_CONSTANT_ENCAPSED_STRING,
T_DOUBLE_QUOTED_STRING,
T_COMMENT,
T_DOC_COMMENT,
];
}
public function process(File $phpcsFile, $stackPtr): void
{
$tokens = $phpcsFile->getTokens();
$content = $tokens[$stackPtr]['content'];
// コメント内の使用が許可されている場合、コメントトークンをスキップ
if ($this->allowInComments === true) {
$tokenType = $tokens[$stackPtr]['code'];
if (in_array($tokenType, [T_COMMENT, T_DOC_COMMENT], true)) {
return;
}
}
// 禁止ワードを含むかチェック
if (stripos($content, $this->forbiddenWord) !== false) {
$phpcsFile->addError(
'"%s" という単語の使用は禁止されています',
$stackPtr,
'ForbiddenWord',
[$this->forbiddenWord]
);
}
}
}
具体的な禁止ワードの追加は以下のようにクラスを実装します。たとえば「コース」を禁止したい場合は、以下のように継承クラスを作るだけです:
<?php
declare(strict_types=1);
namespace AppSniff\Sniffs\ForbiddenWord;
class ForbiddenWordCourseSniff extends AbstractForbiddenWordSniff
{
protected string $forbiddenWord = 'コース';
}
新しい禁止ワードが必要になったら、同じように継承クラスを1つ追加するだけです。コメント内は許可したい場合は $allowInComments = true を設定します:
class ForbiddenWordTreatmentMenuSniff extends AbstractForbiddenWordSniff
{
protected string $forbiddenWord = 'サービスメニュー';
protected bool $allowInComments = true;
}
ruleset.xml ルールセットに除外パターンを書いておくと、テストコードやマイグレーションコードでは禁止ワードを許容できます:
<ruleset name="AppSniff">
<description>App custom coding standard</description>
<rule ref="AppSniff.ForbiddenWord">
<exclude-pattern>*/config/Migrations/*</exclude-pattern>
<exclude-pattern>*/tests/*</exclude-pattern>
</rule>
</ruleset>
メソッド呼び出しの引数チェック
CakePHP の DateTime::createFromTimestamp() はタイムゾーンを第2引数で渡さないとサーバーのデフォルトタイムゾーンが使われます。これ、ローカルでは動くけど本番で時刻がずれる系のバグの温床ですよね。
そこで、第2引数が省略されていたらエラーにする Sniff を作りました:
class DateTimeCreateFromTimestampSniff implements Sniff
{
public function register(): array
{
// :: 演算子を監視する
return [T_DOUBLE_COLON];
}
public function process(File $phpcsFile, $stackPtr): void
{
// (略)
// \Cake\I18n\DateTime::createFromTimestamp() の呼び出しか判定
// use文を考慮して完全修飾クラス名を解決
// 引数が1つ以下ならエラー
if ($argumentCount <= 1) {
$phpcsFile->addError(
'\Cake\I18n\DateTime::createFromTimestamp() の呼び出しでは'
. '第2引数(タイムゾーン)の指定が必須です',
$stackPtr,
'MissingSecondArgument'
);
}
}
}
実例: PHPStan カスタムルール
コアドメインの依存制約
DDD を採用しているプロジェクトで、コアドメイン(App\Domain\*\Core)が外部パッケージやインフラ層に依存していないことを自動検証するルールです。
class ForbiddenCoreDomainDependencyRule implements Rule
{
public function __construct(
private readonly array $allowedDependencies = [],
private readonly array $commonAllowedDependencies = []
) {
}
public function getNodeType(): string
{
return Node::class;
}
public function processNode(Node $node, Scope $scope): array
{
$currentNamespace = $scope->getNamespace();
// コアドメイン以外は検査しない
if (!$this->isCoreDomain($currentNamespace)) {
return [];
}
// new, ::, implements, use 文をチェック
// PHP組み込みクラス、同一ドメイン、許可リストはOK
// それ以外はエラー
return [
RuleErrorBuilder::message(
sprintf('コアドメイン %s は外部の依存 %s を持つことはできません。',
$currentNamespace, $className)
)->identifier('app.forbiddenCoreDomainDependency')->build()
];
}
}
許可リストは neon ファイルで管理します:
parameters:
forbiddenCoreDomain:
# コアドメイン共通で依存を許可する名前空間
commonAllowedDependencies:
- App\Domain\Core
- Psr\Log
# コアドメインごとに許可する名前空間の定義
allowedDependencies:
Billing:
- App\Domain\Payment
- App\Domain\Tax
Reservation:
- App\Domain\Customer
services:
-
class: App\PHPStan\Rules\Domain\ForbiddenCoreDomainDependencyRule
tags:
- phpstan.rules.rule
arguments:
allowedDependencies: %forbiddenCoreDomain.allowedDependencies%
commonAllowedDependencies: %forbiddenCoreDomain.commonAllowedDependencies%
ドメイン間の依存関係がコードで明示的に管理されるので、 知らないうちにコアドメインがインフラ層に依存していた みたいな事故を防げます。
まとめ
こういう開発補助用のツールは完璧である必要はないので、Vibe Coding向きですね。
プロダクションコードだとしっかり見なきゃってなりますけど、開発補助ツールなら多少の漏れがあっても、まぁなんとかなります。
こうやって整備していくことでプロジェクトのルールがコードで明示的に管理されるようになり、レビューコストを下げれるのでおすすめです。
……という話しを、いつぞやのカンファレンスで喋りたかったのを思い出したのでここに供養しておきます。