Claude Media
Claude CodeでDialyzerとCredoの指摘を直す — Elixirの型警告を段階的に

Claude CodeでDialyzerとCredoの指摘を直す — Elixirの型警告を段階的に

既存のDialyzer警告は.dialyzer_ignore.exsに退避し、新規コードだけ@specを足させる。mix credo --strictとmix dialyzerをStop hookで回す手順です。

Elixirの既存プロジェクトにDialyzerを入れると、最初の実行で警告が何十件も出ることがあります。これをClaude Codeにそのまま「全部直して」と頼むと、差分が巨大になり、レビューも追えません。

現実的なのは、既存の警告を .dialyzer_ignore.exs に退避して実行結果を一度ゼロにし、そこから先は「新しく書いたコードは @spec 付き、警告ゼロ」を守らせる進め方です。退避した分は、触るついでに少しずつ減らします。この記事では、その運用をStop hookで機械的に強制する手順を、DialyxirとCredoの公式ドキュメントに沿って組みます。

DialyzerとCredoは何が違うのか

Claudeに渡す指摘が2種類あるので、性質の違いを先に押さえます。

観点mix dialyzer(Dialyxir)mix credo
見るものmix dialyzer(Dialyxir)BEAMバイトコードの型の不整合mix credoソースコードの書き方・一貫性・複雑さ
事前準備mix dialyzer(Dialyxir)PLT(解析結果のキャッシュ)の構築mix credo設定ファイル .credo.exs(任意)
警告を減らす材料mix dialyzer(Dialyxir)@spec、@dialyzer 属性、ignoreファイルmix credo設定の checks、files、設定コメント
厳しさの調整mix dialyzer(Dialyxir)flags で警告の種類を増やすmix credo--strict で低優先度の指摘も含める

DialyxirのREADMEによると、Dialyzerは型の不一致などを検出する静的解析ツールで、型ヒント(spec)を足すと解析を改善できます。ただしspecが無くても役に立つという位置づけです。つまり「specを足すほど警告の精度が上がる」一方で、「specを足す前でも警告は出る」。既存コードの警告を減らす作業と、新規コードへ @spec を足す作業が別物になるのは、この性質のためです。

Credoの方は、指摘ごとに優先度が付き、既定では優先度が正のものだけがレポートに出ます。mix credo --strict を使うと、低優先度を含むすべての指摘が出ます。

全体の進め方

手順

既存の警告を退避して、新規コードから締める流れ

  1. 1

    Dialyxirを入れてPLTを作る

    mix.exs に依存を足し、最初の mix dialyzer でPLTを作ります。時間がかかるのはこの回だけです。

  2. 2

    既存の警告をignoreファイルに退避する

    mix dialyzer --format ignore_file_strict の出力を .dialyzer_ignore.exs に貼り、実行結果をゼロにします。

  3. 3

    Credoを `--strict` で通せる範囲に絞る

    古い領域を files の除外に回し、新しい領域だけ厳格に見ます。

  4. 4

    Stop hookで両方を完了条件にする

    Claudeが応答を終えようとしたときに2つのコマンドを走らせ、失敗なら終了コード2で作業に戻します。

  5. 5

    触ったファイルの退避エントリを消していく

    警告を直すと、対応するignore行が使われなくなります。それを一覧で拾って削除させます。

Dialyxirを入れてPLTを用意する

依存の足し方はDialyxirのREADMEにある形です。only: [:dev, :test] と runtime: false が付きます。

# mix.exs
defp deps do
  [
    {:dialyxir, "~> 1.4", only: [:dev, :test], runtime: false}
  ]
end
mix do deps.get, deps.compile
mix dialyzer --plt

mix dialyzer --plt は、必要なPLTだけを構築して終了するオプションです。初回のPLT構築は、依存が多いほど時間がかかるとREADMEに書かれています。hookの中で初めてPLTを作らせると待ち時間が長くなるので、人間の手元かCIで先に作っておくのが安全です。CIではPLTをキャッシュに載せ、ElixirやErlangのバージョンを変えたときには作り直す、という注意もREADMEにあります。

PLTをリポジトリ配下に置きたいときは、mix.exs の dialyzer キーで場所を指定できます。その場合は .gitignore に /priv/plts/*.plt と /priv/plts/*.plt.hash を足します。

既存の警告を退避する

警告が多い状態でClaudeを走らせる前に、いまある警告を「既知の負債」として固定します。Dialyxirにはそのための出力形式があります。

mix dialyzer --format ignore_file_strict

ignore_file_strict は {file, warning_description} の形で行を出す形式で、READMEは「recommended」としています。もう一つの ignore_file は、ファイルと警告の種類で束ねるため、1行が広く効きます。たとえば同じファイルの no_return が5件あれば、{"lib/something.ex", :no_return} 1行にまとまります。便利ですが、あとからそのファイルに増えた同種の警告まで黙らせてしまうので、新規コードを締める目的なら strict の方が向いています。

出力された行を .dialyzer_ignore.exs のリストに貼ります。ファイル名がこれで、ignore_warnings を指定しなくても既定で探されます。

# .dialyzer_ignore.exs
[
  # {file, warning_description}
  {"lib/something.ex", "Function init/1 has no local return."},
  {"lib/something.ex", "Function refresh/0 has no local return."}
]

上の中身は、READMEが例として挙げている警告文に沿った形です。実際の行は、手元のプロジェクトの出力をそのまま貼ります。貼ったあとで mix dialyzer を再実行し、警告が出なくなったことを確かめてからコミットします。

Credoを段階的に厳しくする

Credoの設定は .credo.exs に書きます。mix credo gen.config で完全な例が生成されます。設定のキーには name、checks、files、strict などがあり、files は included と excluded を持ちます。excluded にはパスのほか、~r"/_build/" のような正規表現も書けます。

Credoの設定ドキュメントには、指摘だらけのディレクトリがあるときの分け方が載っています。既定の設定からそのディレクトリを除外し、そこだけを見る別名の設定を足す方法で、別名の設定は mix credo --config-name <名前> で呼びます。これをElixirのレガシー領域に当てはめると、次のような形になります。

# .credo.exs
%{
  configs: [
    %{
      name: "default",
      files: %{
        included: ["lib/", "test/"],
        excluded: [~r"/_build/", ~r"/deps/", ~r"/lib/my_app/legacy/"]
      },
      strict: true
    },
    %{
      name: "legacy",
      files: %{
        included: ["lib/my_app/legacy/"],
        excluded: []
      },
      strict: false
    }
  ]
}

既定の設定ではレガシー領域を見ず、strict: true で新しい領域を厳しく見ます。レガシー領域は --config-name legacy で別に回せます。これも公式の説明に沿った分け方ですが、legacy というディレクトリ名や設定名は例なので、実際のプロジェクトに合わせて置き換えてください。

一部の行だけ例外にしたいときは、設定コメントがあります。

  • # credo:disable-for-next-line: 次の行だけ無効にする
  • # credo:disable-for-this-file: ファイル全体で無効にする
  • # credo:disable-for-previous-line: 前の行を無効にする
  • # credo:disable-for-lines:<count>: 指定した行数だけ無効にする

disable-for-this-file には、チェック名や正規表現を続けられます(例: # credo:disable-for-this-file Credo.Check.Warning.IoInspect)。Claudeに任せると、指摘を直さずにこのコメントで黙らせる道を選ぶことがあるので、後述するCLAUDE.mdの規約で禁止しておきます。

Credoの終了ステータスは、指摘が無ければ0、あれば1から127です。128以上は解析そのものが失敗した実行時エラーを表します。hookでは「0かそれ以外か」で判定すれば足りますが、128以上が出たときは設定ファイルの構文などを疑う合図になります。

Stop hookで両方を完了条件にする

Stop hookは、メインのClaude Codeが応答を終えたときに走ります。終了コード2で終わると、Claudeは止まらずに会話を続け、標準エラーに書いた文面が続ける理由として渡されます。Stopで終了コード2を返すと「Claudeが止まるのを防ぐ」と、hooksのリファレンスの表にあります。

.claude/settings.json に登録します。

{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/elixir-check.sh",
            "timeout": 600
          }
        ]
      }
    ]
  }
}

スクリプトは次のとおりです。

#!/usr/bin/env bash
INPUT=$(cat)
# すでにStop hookの結果で続行中なら、同じ条件でループしない
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
 
cd "$CLAUDE_PROJECT_DIR" || exit 0
[ -f mix.exs ] || exit 0
 
# Elixir関連の変更が無いターンでは何もしない
if [ -z "$(git status --porcelain -- lib test config mix.exs .credo.exs .dialyzer_ignore.exs)" ]; then
  exit 0
fi
 
if ! out=$(mix credo --strict 2>&1); then
  echo "mix credo --strict が失敗しました。指摘を直してください。" >&2
  echo "コメントでの無効化は使わないでください。" >&2
  echo "$out" | tail -n 60 >&2
  exit 2
fi
 
if ! out=$(mix dialyzer --format short --list-unused-filters 2>&1); then
  echo "mix dialyzer が失敗しました。新規コードに @spec を足すか、型を直してください。" >&2
  echo "$out" | tail -n 60 >&2
  exit 2
fi
 
exit 0

実行権限を付けておきます。

chmod +x .claude/hooks/elixir-check.sh

stop_hook_active の確認は、公式のトラブルシューティングにある形そのままです。Claude CodeはStop hookが8回続けてブロックすると、次のブロックを無視してターンを終えます。この上限は CLAUDE_CODE_STOP_HOOK_BLOCK_CAP で変えられますが、警告が8回で収束しないなら、ヒントの渡し方が悪いはずです。上限を上げる前に、出力の絞り方とCLAUDE.mdの規約を見直す方が先です。

もう一つ、stop_hook_active が true のときに即座に exit 0 すると、Claudeが直した結果は再検証されません。厳密に毎回検査したいときは、このガードを外して上限の8回に任せる設計もありえます。その場合は、直らない警告で8回空回りする可能性を受け入れることになります。

--format short は、Dialyxirのコンパクトな形式で、CI向きとREADMEにあります。構造体などの型情報が落ちるので、Claudeに渡す出力が短くなります。--list-unused-filters は、使われていないignoreフィルタを一覧にするオプションです。--ignore-exit-status を付けずに使うと、終了ステータスがエラーになるとREADMEにあります。警告を直したのにignore行が残っていれば、hookが止めてくれる想定です。この挙動は手元のDialyxirのバージョンで一度確かめてから、運用に載せてください。

hookのテストは、公式ガイドにあるとおり、サンプルのJSONをパイプして終了コードを見るのが手早い方法です。

echo '{"stop_hook_active": false}' | .claude/hooks/elixir-check.sh
echo $?

PostToolUseで編集のたびに走らせる選択肢もあります。ただしDialyzerはプロジェクト全体の解析なので、1ファイルごとに回すには重い処理です。ファイル単位で軽く回せるフォーマッターや静的検査は、PostToolUse hookのLint自動修正の方式が向きます。重い検査をターンの終わりにまとめる考え方は、hooksでテストを自動実行する記事のPostToolUseとStopの使い分けと同じです。

CLAUDE.mdに書く規約

hookは「失敗を知らせる」もので、「どう直すか」の方針は別に要ります。次のような短い規約をCLAUDE.mdに置きます。

## Elixirの型とlint
 
- 新しく書く公開関数(`def`)には必ず `@spec` を付ける
- `mix credo --strict` と `mix dialyzer` が通るまで作業を終えない
- `.dialyzer_ignore.exs` に行を追加しない。減らすのはよい
- `# credo:disable-*` と `@dialyzer {:nowarn_function, ...}` で指摘を黙らせない
- 警告の意味が分からないときは `mix dialyzer.explain <警告名>` を実行する

mix dialyzer.explain は、警告の名前を渡すと説明を出すタスクで、引数なしで実行すると既知の警告の一覧が出ます(READMEの例は unmatched_return)。短い形式の出力には警告名が含まれるので、Claudeはその名前を使って意味を引けます。

@dialyzer {:nowarn_function, ...} は、Dialyzerの組み込みの無視機能です。READMEではRepoモジュールでの例が挙がっています。正当な使い道もありますが、Claudeが指摘を消すための近道にしやすいので、規約で「理由を書いたうえで人間に確認する」扱いに寄せるとよいでしょう。

依頼文の例

退避が済んだら、最初の依頼は範囲を絞ります。

lib/my_app/billing/ 配下のモジュールを対象に、
.dialyzer_ignore.exs の該当エントリを1件ずつ解消してください。
 
- 直した警告に対応する行は、同じコミットでignoreファイルから消す
- 型を直せない場合は理由を説明し、そのエントリは残す
- 対象外のディレクトリには手を入れない

警告を直す作業は、おおまかに次の3種類に分けて頼むと指示が通りやすくなります。

  • 関数に @spec を足す、または既存のspecを実際の戻り値に合わせて直す
  • with や case の分岐を直し、到達しない節や戻り値の取りこぼしを整理する
  • 外部ライブラリが原因の警告は、ignoreに残して理由をコメントに書く

型は人間がレビューで見る価値が高い部分です。Claudeが書いたspecが広すぎる term() だらけになっていないかは、差分で確認してください。同じ「型を段階的に厳しくする」運用は、PHPならPHPStanのlevelを上げる手順、TypeScriptならnoImplicitAnyの有効化にも書いています。

Dialyzerの警告の種類を増やす

退避と新規コードの締め付けが安定したら、もう一段階として警告の種類を増やせます。Dialyxirは0.4以降、既定で追加するフラグは :unknown だけです。mix.exs の dialyzer キーの flags に書くと、unmatched_returns や error_handling、underspecs などを有効にできます。

def project do
  [
    app: :my_app,
    version: "0.0.1",
    deps: deps(),
    dialyzer: [flags: [:unmatched_returns, :error_handling]]
  ]
end

フラグを足すと新しい種類の警告が一気に出るため、ここでも先にignoreファイルを再生成し、差分を「退避した新規の負債」として別コミットにします。コマンド行からは mix dialyzer --unmatched_returns のように個別に試せます。READMEは、フラグによっては初心者に混乱を招く警告が出ると書いているので、1つずつ足すのが穏当です。

落とし穴

フラグを足した直後にhookが毎回止まる

警告の種類を増やすと、ignoreファイルに無い警告が新しく出て、Stop hookが失敗し続けます。フラグを足すコミットと、ignoreファイルを更新するコミットは同時に用意してください。

つまずきやすい点

注意点

運用で詰まりやすい4つの箇所

  • PLTが無い状態でhookが走る

    初回のPLT構築は時間がかかります。先に mix dialyzer --plt で作っておくと、Stop hookの待ちが短くなります。

  • Elixirのバージョンを上げたら全件失敗する

    PLTはElixirやErlangのバージョンごとに作り直しが必要です。バージョン更新の直後は、PLTを再構築してから確認します。

  • ignoreの行が古くなって残る

    {file, warning_type, line} のように行番号を含む形式は、コードが動くとエントリが合わなくなるおそれがあります。strict の形式を使い、使われないエントリは --list-unused-filters で拾います。

  • Claudeが警告を黙らせて終わる

    --ignore-exit-status、設定コメント、@dialyzer 属性は、いずれも警告を残したまま通す手段です。規約とhookのメッセージの両方で禁止しておきます。

Phoenixプロジェクトなら、mix precommit のような既存のエイリアスにCredoやDialyzerを入れる選択肢もあります。その場合の完了条件の置き方はPhoenixのmix precommitを完了条件にする記事が詳しく、本記事のスクリプトは「エイリアスが無いプロジェクトで2つのコマンドを直接呼ぶ」ときの形です。

まとめ

既存の警告を全部直させるより、退避して基準線を引く方が安全です。.dialyzer_ignore.exs で既知の負債を固定し、Credoは設定で領域を分け、Stop hookで「新規コードは両方ゼロ」を強制します。あとは触ったモジュールのエントリを消していくだけで、警告の総数が単調に減ります。

最初に決めるのは、退避する範囲と、規約に書く「やってはいけない黙らせ方」の2点です。この2つが決まっていれば、hookのスクリプト自体は十数行で足ります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks-guide
  2. code.claude.com/docs/en/hooks
  3. hexdocs.pm/dialyxir/readme.html
  4. hexdocs.pm/credo/config_file.html
  5. hexdocs.pm/credo/config_comments.html
この記事を共有:XはてブLinkedIn