🎃

Claude Codeのために「臭うコード検出器」を開発し、Hooksに設定してみた話

に公開

マナリンクCTOの名人です。

コーディングエージェントが書いた大量のコード、レビューしたくないですよね。

もちろん、明確に駄目なものはCIで落とせるようにしたいですが、CIが通っていても、人間のレビュー時点で問題視される実装も大量にあります。

本記事は、既存のレビュー指摘のうち構文木解析で拾えるものを増やし、CIで落としたり、Claude Code Hooksで 「これは絶対NGではないが、一度見直してほしい」 とClaude Code自身に返すなどして運用していく話です。

端的に言えば

  • ESLintやPHPStanが公式でサポートしていないルールを簡単に自作できる時代になった!
  • 自作ルールはCIで弾くだけじゃなく、Claude CodeのStop Hook等で「臭うコード」として通知して自律的に修正させるのもアリ!

という話です。

「臭うコード」の設定後、以下のように、特定のパターンの実装をClaude Codeが実装したあと、勝手にその懸念点に気がついて、勝手に修正してくれるようになっています。

特に以下のような方に読んでほしいです。

  • Claude Codeにコードを書かせる量が増えて、人間レビューの負荷が上がっている
  • CLAUDE.mdにルールを書いても、確率で無視されることに疲れている
  • CIで落とすほどではないレビュー観点を、開発プロセスにうまく混ぜたい

レベル1:明確に駄目な実装を構文木解析で検出する

解決するレビューの種別

  • 構文木でほぼ特定できるようなコーディング規約かつ、これまでCIなどでは落としてこなかったもの

AIコーディング以前も、腕力のあるエンジニアが個人でやっていたとは思いますが、今はClaude Codeにかなり任せられるので、ぜひやろう、という話です。

たとえば、「Laravel標準Loggerではなく、内製Logger Wrapperを使ってほしい」 といった規約が該当します。

人間がコードを書くなら一度伝えたり規約読んでもらえばまず守られるようなものも、AIコーディングでは平気で破られます。

Claude Codeに守らせるには

こういうのをCLAUDE.mdなどに書き始めるとキリがないし、確率で無視されるので疲弊します。

この程度ならカスタムPHPStanルールですぐ組みましょう。
本例は簡単ですが、「このケースは明確に駄目」と人間が言い切れるなら、割と広い範囲で実装できる印象です。ルールがワークするかをClaude Codeに試させたり、テストコードを書かせたりもできるので、実際にやると思ったより捗りました。

カスタムルールを組めたなら、コミットフックやPull RequestのCIで回し、その結果をClaude Codeにフィードバックするといいです。
すでにCIがあるプロジェクトなら、まずはそこに乗せるのが一番わかりやすい落としどころだと思います。

以下にルール例を置きます。人間コーディング時代はこんなのを大量に作っていたら心が折れましたが、今は一瞬なので楽です。(昔から折れていなかった人は、本当に凄いです。)

        if (!$node->class instanceof Name) {
            return [];
        }
        if (!in_array($node->class->toString(), self::FORBIDDEN_FACADE_NAMES, true)) {
            return [];
        }

        $methodName = $node->name instanceof Identifier ? $node->name->toString() : '(unknown)';
        
        return [
            RuleErrorBuilder::message(sprintf(
                'Laravel の Log ファサード (%s::%s) を直接利用することは禁止されています。',
                $node->class->toString(),
                $methodName
            ))
                ->tip('マナリンク内製の Logger ラッパー (中略) を使用してください。Sentry 連携が一貫します。')
                ->identifier('manalink.logger.directLogFacade')
                ->line($node->getStartLine())
                ->build(),
        ];

レベル2:CIで落としづらい実装を「臭うコード」として返す

解決するレビューの種別

  • 許すこともあるが、基本的には駄目だとしたい実装

たとえば、クリーンアーキテクチャベースのプロジェクトでは、「UseCase層でDBアクセスするModel(LaravelやRailsのModel)を直接読んではいけない」 というルールがありますよね。

ガチ新規PJとかならともかく、現実的には「本当は駄目だけど、既存モジュールの都合でこうすることもある。結局レビュー時に判断するしかない」というケースもままあると思います。

で、この手の問題をAIコーディングは凄い量で引き当てます。人間が価値観や経験でケアしていた部分を、Claude Codeは容赦なく裏切ってくるので。

Claude Codeに守らせるには

まず、前節同様、今回のような「臭うコード」を検出するカスタムルールを実装します。
しかしそのままCIなどに組み込むと、偽陽性が多発し、Claude Codeも困って暴走し、トンデモナイ実装にたどり着き、混沌を極めます

そこで、まず臭うコードを検出するカスタムルールだけを実行する専用コマンドを用意します。たとえばPHPStanにはcustomRulesetUsedというオプションが有り、これをONにするとカスタムルールだけを実行する設定ファイルを作れます。
多分、大抵のLintツールにも同じ立ち位置のものがあると思います。

次に、Claude CodeのStop系フックで、本セッションでDiffをもたらしたファイルを引数に、臭うコードを検出するPHPStanを実行し、出力結果に「これは臭っているコードの検知であり、修正必須ではないが、実装の再検討を要求する」旨のコメントを付けて標準出力に出し、Claude Code本体に返す スクリプトを設定します。

  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "bun \"$CLAUDE_PROJECT_DIR/.claude/hooks/stop-check.ts\""
          }
        ]
      }
    ]
  },

このTypeScriptファイルでは、当該セッション内で編集したファイルを受け取り、ファイル名やパスから適切なHandlerにRoutingします。
弊社は実質モノレポ環境で、Claude Codeが全リポジトリ横断で作業しているので、各Handler内で編集ファイルパスを見て、PHPStan、ESLint、ローカルDBへの健全性チェックなど必要なツールセットを呼び出します。

たとえばPHPStanのHandlerでは最後にこのような文言を返します。まだ実験中なので簡易的ですが、要するに「これはエラーではなく、レビューで頻出の指摘項目です。直さないなら、その理由をユーザーに報告してください」ということです。

    return {
      message: [
        `[${this.id}] カスタムレビュー観点ルール (${PHPSTAN_CONFIG}) で指摘が出ました。`,
        'これは絶対 NG ではなく manalink のレビュー指摘パターンです。',
        '方針: 指摘に同意し、その場で修正できるなら直す。直さない場合はユーザへ理由を添えて報告すること。',
        '',
        body,
      ].join('\n'),
    };

Hookの標準出力はClaude Codeのスレッドに返ってくるので、Claude Codeはそれを受けて即座に作業を再開します。重要なのは、人間が再確認を指示する前に、そもそも臭うコードがあったかどうかを気にする前に、Claude Codeが動いてくれる ことです。

他にどんな問題を検知させているか

他にはこういう例もあります。

  • useEffectの中でsetState系の関数呼び出しを検知し、イベントハンドラへの変更を”提案”
    • useEffectだから絶対駄目とは言えませんが、人間が書くuseEffectは6割くらい駄目だし、Claude Codeが書くそれは9割くらい駄目なので、一律NGにはせず、その場でFeedbackしたいと思ってます
  • Validationルールを扱うクラスで、最大値の設定漏れがある
    • 絶対駄目ではないけど、要件定義でうっかり明示しなかったときにそうなりがち。普通は最大値が有るものなので”臭う”
    • 即座に修正させるより、要件定義書を再確認し、指示者に質問させるのが正しい方向性っぽい
  • あるテーブルXの主キーに外部キーを貼っているテーブルは、普通はON DELETE CASCADEするが、稀に例外が有るケース
    • MigrationファイルにDiffがあるとき、ローカルDBにSQLを発行して静的検知します
    • テーブル設計時の漏れや、Claude CodeにMigrationファイルを書かせたときの漏れを検知できます
  • テストで assertStatus(500) を期待している箇所を検知する
    • AIにテストを書かせると、実装中に500が出たときに「現在の挙動が500だから」とそのまま期待値にしてしまうことがある
    • 500だから絶対駄目とは限りませんが、実装が終わった後にHookで返すことで、自律的に「あれ、これは良くないことしちゃったかも」とAIが気づけるようにします
  • 本来は1箇所からしか触ってほしくないInterfaceを、別クラスが直接DIしている箇所を検知する
    • PHPにはパッケージプライベートのような境界が標準では無いので、触れてしまうものは触れてしまう
    • こういう数クラスだけの依存関係なら、専用ツールを入れなくてもPHPStanのカスタムルールで十分検知できる

Validationの最大値漏れはこんな感じです。Claude Codeに書かせたのでMessageが英語ですが、まぁええでしょう。

                $ruleStrings = $this->extractRuleStrings($item->value);
                if ($ruleStrings === null) {
                    continue;
                }

                $hasString = false;
                $hasMax = false;
                foreach ($ruleStrings as $ruleString) {
                    if ($ruleString === 'string') {
                        $hasString = true;
                    }
                    if (str_starts_with($ruleString, 'max:') || str_starts_with($ruleString, 'size:')) {
                        $hasMax = true;
                    }
                }

                if ($hasString && !$hasMax) {
                    $fieldName = $item->key->value;
                    $errors[] = RuleErrorBuilder::message(sprintf(
                        'FormRequest field "%s" declares the "string" rule but has no "max:N" (or "size:N") upper bound. '
                        .'Strings without an explicit max can produce 500 errors on non-string payloads or be abused for DoS. '
                        .'Examples: [\'required\', \'string\', \'max:16\'] for codes, or "required|string|max:1100" for free text.',
                        $fieldName,
                    ))
                        ->identifier('manalink.http.formRequestStringMissingMax')
                        ->line($item->getStartLine())
                        ->build();
                }

設定した結果、観測された挙動

Stop Hookで実行され、結果メッセージが流れ込むと、Claude Codeはまた作業を再開し、検討し、必要に応じて実装を進めます。言ってしまえばRalph Loopの応用編というか、静的度を高めたバージョンですね。

また、勝手に解消するばかりではなく、「Hookからは駄目って返ってきましたが、解消する手立てもないし、意味もないと思ったので、やりませんでした」ということももちろんあります。

ここで重要なのは、大量の差分の中から「ここが臭うことが有るよ」と人間が事前に定義したところを、Claude Codeから報告してくれることです。

個人的にはこれだけでも相当な進歩です。人間でも、実装時に判断に迷ったところはテックリードに相談すると思いますが、それに近い動きになります。

というわけで、臭うコードの検知 × Stop Hookの組み合わせは、今のところ気に入っています。

スタンス

スタンスとしては、これは割と手軽に量産していいと思っています。CIが落ちるかどうか、という命題にするとチームの許可が欲しいとか欲しくないとかいう話になりがちですが、Stop HookでClaude Codeに返すだけなら、致命的な問題は起きづらいです。実際、ちょっと思想強めのWarnを入れた数日後に同僚から「名人さん、あのルールさすがに思想強くないですか」とツッコミを受けたこともありますが、開発をどうしても止めるわけではないので、そう言われたらその後考えたら良いです。

このルールを増やすこと自体はプロダクトコードに変化を及ぼさないので軽率に開発できます。だからこそ量産して回し、どこまでのルールが今の組織やフェーズにマッチするのかを実験する方に価値が有ると思ってます。

蛇足

実際に組み始めるとわかるんですが、これ下手に実装すると無限ループになっちゃうので、以下のようなガードを入れると良いです(どうせ実装はClaude Codeにさせるので、これを見せるだけで伝わると思います)。

# stop_hook_active=true のときは「Claude が既にこの hook に応答して継続中」なので、
# 無限ループを避けるため指摘をスキップする。
if [ "$(echo "$payload" | jq -r '.stop_hook_active // false' 2>/dev/null)" = "true" ]; then
  exit 0
fi

蛇足2

すでに成熟したチームでこれを始めたい人は、settings.local.jsonで設定し、手元だけで試すところからスタートすると良いかもしれませんね。
諸事情でフックをいじれない場合は、任意のDiffを解析して臭うコードを返すラッパーを作り、一段落するたびに手動でトリガーすることでも代用できそうです。なので、別にHookである必要はそこまで大きくありません。スケーラビリティの違いくらいです。

なお、Stop Hookではなくgitコミットフックでもできなくはありません。ただ、コミットフックには明示的に駄目なルールを入れたいし、「臭うコード」の話はClaude Code自身のアウトプット能力底上げというスコープなので、Claude Code内に閉ざしたい。そういう感覚的な理由で、Stop Hook側に寄せています。

Claude Code Rules & サブディレクトリのCLAUDE.md

次は構文木解析の話から外れますが、レビューの手間を減らす文脈で、Claude Code RulesやCLAUDE.mdを活用する話にも触れておきます。

これまでの話は、実装後のファイルに即座にFeedbackを返す、という指向性でした。そのため、構造的な限界があります。
たとえば、特定の知識を知らなかったせいで設計判断からコケてしまい、とんでもない実装を仕上げてくるケースはケアしづらいです。

そこで、実装に取り掛かる前から気づいてほしい項目は、Claude Code RulesでPathを指定して書いたり、関連モジュールに近いサブディレクトリにCLAUDE.mdを置いたりしています。

ただ、この方式は構文木解析のような根拠のある検出を使えないため、ある程度汎用的な指示しか書けない限界があります。
改善案としては、読み取ろうとしているファイルが特定の構文木解析条件に引っかかる場合に「設計でここに気をつけろ」と伝えられれば、より静的にできそうなので、今後研究してみたいです(PlanモードかつReadツールのPreToolUse Hook内で発火できるなら、計画時点で言いたいことを指示できるかも)。

これらのルールを作ってくれるSKILLを開発する

ある程度方向性が固まり、事例がいくつかできてきたら、ルール自体を作ってくれるSKILLを作ると捗ります。

静的解析できるものを最上位にし、静的解析したものを「臭う」としてClaude Codeにフィードバックするのを次点、それでも無理なものをClaude Code Rulesなどに落とし込む、という優先度で動かします。

引数でGitコミットなどを渡し、「このコミットで行った修正を、今後は事前に防げるようにルール化したい。計画出して」 と言うと、対応する構文木解析の調査や既存事例の塩梅を踏まえて、それっぽい検出器とルールの提案をくれるようになります。

ここまでくると、Claude Codeが「それちょっと違うんだよなぁ〜」という実装をしてきたとき、まず指示して修正させ、コミット後に別スレッドでこのスキルを呼ぶ、という習慣を作れます。やればやるほど再発防止策が溜まっていきます。

人間のPull Requestレビューも残す

少なくとも弊社では、人間のレビューもプロセスとして残しています。ただ、レビューの時点でも「この施策の特性上、この辺が臭そうだけど、特別なケアをしているかな」みたいに、コードを見る前に当たりをつけるのが当然になっています。

そうなると、いかにレビュワーに「この施策はこの辺が怪しいと思いますよ」を提示できるかの勝負になります。今のところ、「臭うコード」として扱っている構文木解析のいくつかは、Claude Codeに見せるより人間のレビュワーに見せる方が良さそうだと思うものがあります。それらはPull Requestに「レビュワーへのヒント」としてReviewDogでコメントさせる仕組みを入れたりしています。

こういうのがPRコメントされるので、人間の目でレビューするときに、「こういう実装があるということはなんか大きな設計の歪みがあるのでは!?」と思ってそこから深堀りできたりする。

今後の課題

  • Stop Hookではなく、PostToolUseのほうが良い可能性
    • "matcher": "Edit|Write|MultiEdit" などで限定すれば、同様のフックをほぼ動かせそう
  • スクリプトが増えるとClaude Codeにフィードバックを返す部分が肥大化するため、フレームワークっぽくアーキテクチャを整えたい
    • 複数言語を横断して動くことになるし、ファイルパスの知識をどこに持たせるのかなど、ブラッシュアップの余地がある
  • APIやDBスキーマなど、リリース後に安易に変えられない差分へ、より強いフィードバックを返す仕組み
    • API設計を提案した後、そのとおりに実装してくれたものの、後で見ると設計が歪んでいて、フロントエンドでめちゃめちゃ吸収していたことがある。指示追従性が上がっているからこそ、ワークフロー的に別の意図を持ったClaude Codeを起動する仕組みが必要そう

まとめ

AIコーディング時代のレビュー負荷を下げるには、単に「AIにちゃんと書け」と頼むだけでは足りません。

  • 明確に駄目なものは、カスタムLintやPHPStanルールとしてCIで落とす
  • CIで落としづらいけれどレビューでは気になる実装は、「臭うコード」としてClaude Code Hooksに返す
  • 静的に検出しづらい設計知識は、CLAUDE.mdやRules、SKILLに落とし込む

というように、レビュー観点をいくつかの形に分けて開発プロセスへ乗せていくのが良さそうです。

特に「臭うコード」をStop HookなどでClaude Code自身に返す運用は、人間がレビューする前に一度立ち止まらせられるので、今のところかなり気に入っています。

補足:構文木解析以外ですでに試していること

本記事の本筋とはズレますが、ソースレビュー自動化文脈で私が試したものも軽く列挙しておきます。

  • 静的解析ツールの既存ルールを増やしたり、新しいツールを入れる
  • 過去3年以上の私のソースレビューコメントをGitHub APIでダウンロードし、AI自身に分析させ、それをSKILLファイルとしてまとめる
    • 「仕様・データ・ロジックが「正しい場所」に住んでいるか。問題を下流で対処するのではなく、源流で解決しているか」がmeijinのレビューの最も根底にある原則。 とSKILL.mdに書かれました。そうなんですね〜
    • 何回か実際にClaude Codeが大量生成したPRに当ててみましたが、割とワークしてます。明らかに無いよりはマシです。
  • 循環型複雑度など、静的に測定できるアーキテクチャ関連の指標をとりあえず大量に回して、決めた基準を下回ると警告を出す
    • 特定の指標がこうなっていたらこうしたほうがいい、というのがどうにも決めきれなくて、途中でやめちゃいました。
    • なんていうか、具体例無しで抽象的に課題を定義してそれですべてが解決するのでは、みたいなアイデアって、ことごとく上手く行かないんですよね・・・そりゃそうか。

試した経験から学んだこと

以上の経験から、以下を学びました。

  • 具体的な事例を見せるほど、AIは地に足のついたレビュー項目を出してくれる
  • レビュー実績の多いプロダクトや個人は、その資産を活かしたほうがいい
  • 具体例無しで抽象的に課題を定義して「AIが見たことのない課題を見つけてくれるのでは」と期待するのは幻想。まず自分やチームがレビューしたことが有る、指摘したことが有る課題から始める方が無難

所感

Claude Codeのステータスラインに以前から猫を飼っているのですが、これのおかげでHooksの概念理解が進んだので、そこでレビュー指摘を返そうという発想になりました。猫のおかげです(?)

https://github.com/TeXmeijin/claude-code-mascot-statusline

マナリンク Tech Blog

Discussion