Claude Media
Claude CodeでHaskellを書く — stack buildとhlintを検証ループに固定

Claude CodeでHaskellを書く — stack buildとhlintを検証ループに固定

Claude CodeにHaskellを書かせるとき、stack build --fastとhlintで型エラーの反復を閉じる運用と、cabal利用時の読み替え、CLAUDE.mdとhooksの設定例をまとめます。

Claude CodeにHaskellを書かせるなら、stack build --fastで型を通し、hlintで書き方を整える、という二段を毎回の合格条件にします。この記事では、その二段をCLAUDE.mdに固定する書き方、cabalで同じことをする読み替え、編集のたびにhlintを走らせるhooksの設定を順に示します。

Haskellは、型検査を通れば大筋の振る舞いが固まりやすい言語です。裏返すと、AIが書いたコードの最初の関門はコンパイラになります。この関門を人がコピー&ペーストで往復する構成だと、型エラーの数だけ手間が増えます。Claudeが自分でビルドして自分で読む形にしておけば、人が見るのは通った後の差分だけで済みます。

Haskellの型エラーを潰すstack buildのフラグ選び

新規プロジェクトはstack new my-projectで作り、stack buildでビルドし、stack exec my-project-exeで実行します。初回のビルドでは、必要ならStackがGHCを独立した場所にダウンロードします。システムにある別のGHCには干渉しません。

修正ループで使うのは、stack buildの次のフラグです。

フラグ役割ループでの使いどころ
--fast役割GHCに-O0を足す。最適化を切ってコンパイルを速くするループでの使いどころ型エラーを潰す反復の既定
--pedantic役割GHCオプションの-Wallと-Werrorでビルドするループでの使いどころ仕上げの確認
--test役割テストスイートをビルド対象に加える。stack testも同じ意味ループでの使いどころ挙動の確認
--file-watch役割ファイルが変わるたびに再ビルドするループでの使いどころ人が横で見ているとき

--fastは、最適化にこだわらずに速くコンパイルする設定です。型を通すだけの反復なら、最適化されたコードは要りません。一方で、--fastの効果は--ghc-optionsで上書きできます。CLAUDE.mdに--ghc-optionsで-O2を足すような指示が混ざっていると、--fastが効かなくなる点は覚えておきます。

--pedanticは警告をすべてエラーに変えます。反復の途中でこれを付けると、型は正しいのに未使用の変数で止まる、という足止めが増えます。そのため、普段は--fastだけで回し、一段落した最後に1回だけ--pedanticを通す、という順にするのが扱いやすい形です。

# 反復の既定(型を通すだけ)
stack build --fast
 
# 仕上げ(警告もエラー扱い)
stack build --fast --pedantic
 
# テストも含めて確認
stack test --fast

stack testはstack build --testの別名なので、--fastもそのまま併用できます。

--file-watchは、人間が別ウィンドウで常時ビルドを回す用途には向きますが、Claudeが1回ずつ呼ぶ検証コマンドには向きません。待ち受けが終わらず、コマンドが返ってこないためです。Claudeに渡すのは、結果を出して終了するコマンドだけにします。

cabalで回すときの読み替え

Stackを使わず、cabalでプロジェクトを回している場合も、考え方は同じです。cabal buildはターゲットを指定してビルドし、依存も自動で扱います。ただし注意が2つあります。

  • パッケージのディレクトリ(.cabalファイルのあるところ)の外でcabal buildやcabal testを叩くと、「ターゲットが指定されておらず、カレントにパッケージもない」というエラーになる。複数パッケージのプロジェクトでは、allなどのターゲットを明示する
  • テストスイートとベンチマークは、既定ではビルド対象に入らない。--enable-testsで明示するか、cabal testを使う

cabal testは、ビルドが必要ならビルドしたうえでテストスイートを走らせます。ドキュメントには、cabal testとcabal runは作業ディレクトリが異なる、という注意もあります。前者はテストスイートを持つパッケージのディレクトリから実行し、後者はコマンドを打ったカレントディレクトリから実行します。テストがパッケージ内のファイルを読む作りだと、cabal runでは動かないことがあります。CLAUDE.mdには「テストはcabal testで実行する」と書き、Claudeにcabal runで代用させません。

目的Stackcabal
型を通すStackstack build --fastcabalcabal build all
テストStackstack testcabalcabal test all
REPLで確認Stackstack repl(stack ghciも可)cabalcabal repl

cabalで最適化を切るときは、利用中のcabalのドキュメントで--ghc-optionsの扱いを確かめます。

Haskellの書き方までhlintで検証する

型が通っても、concat (map f x)のような冗長な書き方は残ります。hlintは、Haskellのコードに対して書き換えの提案を出すリンターです。インストールはcabal updateのあとcabal install hlintで、hlint <ファイルまたはディレクトリ>で実行します。ディレクトリを渡すと、配下の.hsと.lhsを再帰的に探します。

公式READMEにある出力は、次の形です。

darcs-2.1.2\src\CommandLine.lhs:94:1: Warning: Use concatMap
Found:
    concat $ map escapeC s
Perhaps:
    concatMap escapeC s

1件ごとに、場所、重大度(Warning・Suggestionなど)、ヒント名、検出した式(Found)、置き換え案(Perhaps)が並びます。この構造は、Claudeに読ませるのに向いています。「Foundの式をPerhapsに直す」という指示が、出力の中にすでに書かれているためです。

ただし、READMEには次の注意もあります。ヒントの多くは「たいていの場面で良い」ものであって、常に正しいわけではありません。Warningは「常にそうしたほうがよい」もの、Suggestionは、追加のimportが要る、好みが分かれる、新しいbaseライブラリでしか使えない関数を含む、といった理由で提案にとどまるものです。他人のプロジェクトに貢献するときは、hlintの規則に合わせるだけの変更をしない、とも書かれています。AIに「hlintが出したものは全部直せ」と指示すると、この判断を飛ばしてしまいます。

既存プロジェクトに入れる順番

READMEが勧める進め方は3段階です。

手順

hlintの導入順

  1. 1

    レポートを出す

    hlint . --reportで、全指摘をHTMLのreport.htmlにまとめます。直す価値のあるものを直して繰り返します。

  2. 2

    残りを凍結する

    納得できたらhlint . --default > .hlint.yamlで、残った指摘をすべて無視する設定ファイルを作ります。

  3. 3

    ヒントを足す

    大きなプロジェクトでは、独自のヒントや制限を足します。

既存のコードベースにいきなり「hlintゼロ」を求めると、Claudeは無関係なファイルまで書き換え始めます。2の--defaultで現状の指摘を一度すべて凍結し、そこから先の新しい指摘だけをゼロにする運用が現実的です。.hlint.yamlは、カレントディレクトリか親ディレクトリで最初に見つかったものが同梱の設定に加えて読まれます。

特定のヒントだけを止めたいときは、ヒント名で指定します。ヒント名は、警告の見出しに表示される文字列そのものです。たとえばWarning: Redundant ==と出たなら、名前はRedundant ==です。

# .hlint.yaml
- ignore: {name: "Eta reduce"}
- ignore: {name: "Redundant $", within: [MyModule1, MyModule2]}

ソース側のpragmaでも止められます。{-# HLINT ignore "Eta reduce" #-}のように書き、定義名を付ければ、その定義だけが対象になります。ANN形式のpragmaは、TemplateHaskellで評価されるためコンパイル時間や再コンパイルが増える場合がある、とREADMEは注意しています。Claudeに「抑制が必要なときはHLINT pragmaを使い、ANNは使わない」と指示しておくのは、この点に基づく割り切りです。

自動修正と、変更したファイルだけの実行

hlint --refactorは、一部のヒントを自動で適用します。使うにはapply-refactパッケージのrefactorコマンドがPATHにある必要があり、すべてのヒントが対応しているわけではありません。Claudeに任せるなら、--refactorで一括適用するより、指摘を読んで1件ずつ直させるほうが、差分を確認しやすくなります。

大きなコードベースでは、変更のあったファイルだけに絞れます。READMEには、git diffとgit ls-filesで.hsファイルを列挙してxargs hlintに渡す例があります。

起きること原因対処
パースエラーが出る原因hlintが既定で有効にしない拡張を使っている対処.hlint.yamlに- arguments: [-XQuasiQuotes]のように拡張を足す
独自演算子の優先順位を誤る原因hlintは1ファイルずつ処理し、importを解決しない対処.hlint.yamlに- fixity: "infixr 5 !@%$"の形で宣言する
どのヒントが無効か分からない原因一部は既定で無効、または設定で無視されている対処hlint --showで一覧する

CLAUDE.mdに二段の検証ループを書く

ここまでをCLAUDE.mdにまとめます。Claude Codeのドキュメントによれば、CLAUDE.mdは強制される設定ではなく、文脈として読み込まれるものです。そのため、確認できる具体的な形で書くことが勧められています。1ファイルは200行以内が目安です。

# Haskell 開発ルール(Stack プロジェクト)
 
## 検証ループ(変更のたびにこの順で実行)
1. `stack build --fast` を実行する。エラーは最初の1件だけを読み、
   原因を1行で述べてから直す。まとめて複数を直さない
2. 通ったら `hlint src app test` を実行する
3. 指摘が出たら Found / Perhaps を読み、置き換えが意味を変えないか
   確認してから直す。納得できないものは直さず、理由を報告する
4. 一段落したら `stack build --fast --pedantic` と `stack test` を1回通す
5. 実行したコマンドと結果を報告する
 
## 触るファイル
- 依存の追加は `package.yaml` の `dependencies:` に書く
- `*.cabal` は Stack が `package.yaml` から生成するので、直接編集しない
- Stack が「バージョンが未指定」と報告したときだけ、`stack.yaml` の
  `extra-deps:` に追加する。他の用途で `stack.yaml` を編集しない
 
## やってはいけないこと
- 型エラーを消すために `undefined` や `error` で本体を埋めない
- テストを通すためにアサーションを弱めたり削除したりしない
- `--ghc-options` で `-O2` を足さない(`--fast` が効かなくなる)
- `stack build --file-watch` を実行しない(コマンドが終了しない)
- hlint の指摘を消す目的だけで、無関係なファイルを書き換えない

「触るファイル」の節は、Stackの作法に基づきます。stack newで生成されるプロジェクトには、package.yaml、stack.yaml、my-project.cabalが並びます。Stackのドキュメントによれば、.cabalファイルはpackage.yaml(Hpack形式)から生成され、依存するパッケージを増やしたいときはpackage.yamlのdependencies:に名前を足します。package.yamlを消せば、Stackは.cabalファイルを使うようになります。どちらを正とするかを決めずにClaudeに任せると、生成物のほうを直接編集して、次のビルドで上書きされる、という往復が起きます。

「undefinedで埋めない」の一行は、このループの要です。型エラーに対する最短の「解決」は、本体をundefinedにして型だけ通すことです。stack buildは通りますが、実行すると落ちます。検証コマンドを固定しても、合格条件を動かされては意味がありません。同じ考え方は、Kotlinのtestapplicationで検証する手順や、Solidityのforge testを回す手順でも使えます。Python側の同型はruffとmypyの品質ゲートにあります。

Claudeが型エラーを読む順番

ビルドが複数のエラーを出したとき、後ろのものは前のエラーに引きずられている場合があります。CLAUDE.mdで「最初の1件だけを読む」と決めておくと、引きずられたエラーに合わせて別の場所を直す無駄を避けられます。これは運用上の方針で、公式の推奨ではありません。1件直してstack build --fastを再実行する、という短い往復に分けることで、各ステップの差分が小さく保たれ、人間のレビューもしやすくなります。

型の分からない式は、stack repl(stack ghciも同じ)で確かめさせる方法もあります。REPLは対話型なので、stack buildのように「出力を読んで終わり」とはなりません。Claudeには、:tで式の型を確かめたいときだけ使う補助として伝えます。

hooksで編集のたびにhlintを走らせる

CLAUDE.mdは文脈にすぎません。「編集のたびに必ずhlintを通す」を強制したいなら、hooksを使います。PostToolUseは、ツール呼び出しが成功した直後に動きます。Claude Codeのドキュメントによれば、PostToolUseのフックが終了コード2で終わると、標準エラー出力がClaudeに見える形で渡されます。ツールはすでに実行済みなので、編集自体は取り消されません。つまり、「編集後に指摘をClaudeへ返す」用途に合います。

.claude/settings.jsonには、次のように書きます。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/hlint-on-edit.sh"
          }
        ]
      }
    ]
  }
}

スクリプトは、標準入力のJSONから編集されたファイルのパスを取り出し、.hsファイルのときだけhlintを実行します。

#!/bin/bash
# .claude/hooks/hlint-on-edit.sh
file=$(jq -r '.tool_input.file_path')
case "$file" in
  *.hs) ;;
  *) exit 0 ;;
esac
 
if ! out=$(hlint "$file" 2>&1); then
  echo "$out" >&2
  exit 2
fi

hlintは指摘があるときに非ゼロで終わることを前提にした書き方です。導入前にhlint src; echo $?を一度実行し、指摘があるときの戻り値を確かめます。0で返る環境なら、出力が空かどうかで判定する形に直します。

ビルドのstack build --fastもフックに入れたくなりますが、編集のたびに走ると、複数ファイルにまたがる変更の途中で必ず失敗します。ビルドはClaudeが自分で呼ぶ検証コマンドにとどめ、フックには軽いhlintだけを載せる、という分担にしておきます。テストが通るまで止めない動きを作る方法は、agent hookで「テストが通るまで止めない」を検証させる記事に詳しくあります。

permissionsでビルド系を通し、危ない操作を止める

検証コマンドを毎回承認していると、反復が止まります。ビルド系とhlintは許可し、パッケージ操作やアップロード系は止めます。

{
  "permissions": {
    "allow": [
      "Bash(stack build *)",
      "Bash(stack test *)",
      "Bash(stack ghci *)",
      "Bash(hlint *)",
      "Bash(cabal build *)",
      "Bash(cabal test *)"
    ],
    "ask": [
      "Bash(stack install *)",
      "Bash(stack upgrade *)",
      "Bash(cabal install *)",
      "Edit(stack.yaml)"
    ],
    "deny": [
      "Bash(cabal upload *)",
      "Bash(stack upload *)"
    ]
  }
}

stack installは、Haskellパッケージが提供する実行ファイルをインストールするコマンドです。ホスト環境へのインストールを伴うため、承認制にしています。stack.yamlの編集もaskに置いたのは、スナップショットの選択やextra-depsがここで決まり、ビルドの再現性に直結するためです。

この運用が向く場面と向かない場面

向くのは、型が既に固まっていて、関数を足す・直す作業です。型エラーという明確な失敗信号があり、Claudeが原因を読んで直せます。逆に、型の凝ったコードでは、Claudeが1回の修正で型エラーを解消できない場合もあります。その場合は「最初の1件だけ」の原則に加えて、直す前に、関係する関数の型シグネチャを書き出させる、という手順をCLAUDE.mdに足すと安定します。

hlintについては、提案の置き換えが意味を変えないとは限りません。READMEは「提案された置き換えは等価であるべきで、そうでないものは報告してほしい」としています。一方、既知の制約として載っているものは例外です。置き換えた後にstack testが通ることを、必ず検証ループの末尾に残します。

まとめ

Haskellでは、stack build --fastが型の合否、hlintが書き方の合否になります。この二段をCLAUDE.mdに順番ごと書き、undefinedで埋めない・アサーションを弱めないという禁止事項を添えると、反復の質が決まります。強制したい部分(編集のたびのhlint)はhooks、承認の手間を減らす部分はpermissionsに分けて置きます。

よくある質問

hlintをCIにも入れられますか?

READMEには、CIでhlint .を実行する例があります。コンパイルの時間を避けるなら、最新のバイナリリリースを取得する方法も案内されています。CIに入れるかどうかは、.hlint.yamlで現状の指摘を凍結してから決めると、無関係な失敗に悩まされません。

この記事を共有:XはてブLinkedIn