Claude Codeでjqのフィルタを書かせる — 入力サンプルと期待出力で検証
jqのフィルタは、書けたように見えても本物のデータで落ちます。入力サンプルと期待出力を渡し、実行して差分を見る検証の組み方を、nullと欠損キーの具体例で示します。
jqのフィルタは、自然文で頼むと「それらしいもの」がすぐ返ってきます。厄介なのは、手元の2〜3行では動くのに、本物のデータで初めて落ちる点です。nullの項目、欠けたキー、空の配列が1件混ざるだけで、結果が変わるか、エラーで止まります。
対策は単純で、Claude Codeに渡す材料を「欲しい処理の説明」から「入力サンプルと期待出力」に替えることです。生成されたフィルタを実データの一部で実行させ、出力を期待出力と突き合わせます。この記事では、注文データを集計するフィルタを題材に、その手順と、パイプで渡す入力を絞る方法を組みます。出力はすべて、手元のjq 1.7.1-apple(macOS)で実行したものです。
jqのフィルタは、動いたように見えても検証にならない
jqはデータを加工する言語ですが、落ちるかどうかは書いた式ではなくデータ側で決まります。
次のような注文データ(orders.json、4件)があるとします。
[
{"id":"A-1","status":"paid","customer":{"name":"Sato","email":"sato@example.com"},
"items":[{"sku":"pen","price":120,"qty":3},{"sku":"ink","price":800,"qty":1}]},
{"id":"A-2","status":"refunded","customer":{"name":"Suzuki","email":null},
"items":[{"sku":"pen","price":120,"qty":10}]},
{"id":"A-3","status":"paid","customer":{"name":"Sato","email":"sato@example.com"},
"items":[{"sku":"pad","price":450}]},
{"id":"A-4","status":"paid","customer":{"name":"Tanaka"},"items":[]}
]欲しいのは「支払い済みの注文を、メールアドレスごとに合計金額で集計した配列」です。メールが無い注文は (unknown) にまとめ、末尾に置きます。
このデータには、わざと罠を入れました。A-3は qty が無い。A-4は email キーそのものが無く、items も空。A-2は email がnullですが、返金済みなので集計の対象外です。
最初の案として、次のようなフィルタが出てきたとします。
jq '[.[] | select(.status=="paid")
| {email: .customer.email,
total: ([.items[] | .price * .qty] | add)}]' orders.json実行すると、次のエラーで止まります。
jq: error (at orders.json:9): number (450) and null (null) cannot be multipliedqty の無いA-3で落ちました。先頭の1件だけを見ていれば、このフィルタは正しく動いたはずです。サンプルが先頭だけだと、この種の欠落は見つかりません。
渡す材料は3点: 入力サンプル・期待出力・実行コマンド
公式のベストプラクティスは、Claudeが自分で正否を確かめられる手段を与えることを勧めています。手段の例として、テストファイル、リンター、「出力を固定ファイルとdiffするスクリプト」が挙がっています。jqのフィルタは出力が決定的なので、この「diffするスクリプト」の型がそのまま当てはまります。
プロンプトには次の3点を入れます。
フィルタ生成を頼むときに渡すもの
- 1
入力サンプル
本物のデータから、癖のある行を意図して選んだ数件です。先頭の数件ではなく、null・欠損・空配列を含む行を混ぜます。
- 2
期待出力
手で書いた正解のJSONです。ここを人間が決めるのが肝で、フィルタの結果をそのまま期待値にしないでください。
- 3
実行コマンドと合格条件
「
jq -f filter.jq orders.jsonの出力がexpected.jsonと一致するまで直す」と書きます。一致の判定はコマンドに任せます。
期待出力は、今回なら次の2要素の配列です。
[{"email":"sato@example.com","total":1610},{"email":"(unknown)","total":0}]1610は、A-1の1160(360 + 800)と、qty無しを1個と数えたA-3の450の合計です。「qtyが無ければ1個」は仕様の判断なので、プロンプトにも明記します。書かなければ、その行を0円として扱うか、除外するか、Claudeが黙ってどれかを選びます。
プロンプトの例です。
orders.json の支払い済み注文を、メールアドレスごとに合計金額で集計する
jq のフィルタを filter.jq に書いて。
- 期待出力は expected.json。qty が無い明細は1個として数える
- メールが無い、または null の注文は "(unknown)" にまとめて末尾へ
- ./check.sh filter.jq orders.json expected.json が OK を出すまで直して、
最後の実行結果をそのまま見せて最後の一行は、公式が勧める「実行したコマンドとその出力という証拠を求める」使い方です。「通りました」という報告だけでは、どのデータで通ったのか分かりません。
反復の中身: エラー、静かな誤り、一致の3段階
同じデータで直していくと、フィルタは3段階を踏みます。
| 段階 | 変えた点 | 手元の結果 |
|---|---|---|
| v1 | 変えた点素朴な .price * .qty | 手元の結果A-3でエラー終了 |
| v2 | 変えた点.qty // 1 と .email // "(unknown)" を追加 | 手元の結果エラーなし。ただし集計が崩れている |
| v3 | 変えた点add // 0、group_by での集約、並べ替えを追加 | 手元の結果期待出力と一致 |
v2が一番危険です。エラーが消えてJSONが出るため、動いたように見えます。実際の出力は次のとおりです。
[{"email":"sato@example.com","total":1160},{"email":"sato@example.com","total":450},{"email":"(unknown)","total":null}]Satoが2行に分かれたまま、集約されていません。しかも (unknown) の合計は0ではなくnullです。空の配列に add をかけるとnullが返るためで、[] | add がnullになることも手元で確かめました。
期待出力とのdiffを取れば、この2点は一目で分かります。出力を目で眺めて「動いた」と判断する運用だと、v2で止まります。
最終版の filter.jq は次のとおりです。
[ .[]
| select(.status == "paid")
| { email: (.customer.email // "(unknown)"),
total: ([.items[] | .price * (.qty // 1)] | add // 0) }
]
| group_by(.email)
| map({ email: .[0].email, total: (map(.total) | add) })
| sort_by(.email == "(unknown)", .email)sort_by の第1キーに .email == "(unknown)" を置くと、falseが先、trueが後に並びます。これで (unknown) が末尾に回ります。
検証をスクリプトに固定する
毎回「実行して比べて」と頼むと、比べ方が揺れます。diffの取り方はスクリプトにして、Claudeには一つのコマンドだけを渡します。
#!/usr/bin/env bash
# 使い方: ./check.sh filter.jq orders.json expected.json
diff <(jq -S -c -f "$1" "$2") <(jq -S -c . "$3") && echo OK-S はキーを並べ替え、-c は1行に整形します。キー順や空白の違いを差と見なさないための指定です。<(...) はプロセス置換で、sh では動かないため先頭は bash にします。
一致しなければ、diff が差分を出して終了コード1を返します。期待値を [{"email":"sato@example.com","total":1160}] に差し替えて試すと、次のように出ました。
1c1
< [{"email":"sato@example.com","total":1610},{"email":"(unknown)","total":0}]
---
> [{"email":"sato@example.com","total":1160}]Claudeはこの出力を読んで、どの要素が食い違ったかを直接つかめます。
繰り返し使うなら、CLAUDE.md に数行置いておきます。公式のベストプラクティスは、検証を走らせるコマンドを CLAUDE.md に書く行が最も役に立つと述べています。
## jq のフィルタ
- フィルタは filter.jq に書く。検証は ./check.sh filter.jq <入力> <期待出力>
- 入力サンプルには null・欠損キー・空配列を含む行を必ず入れる
- 期待出力は人間が決める。フィルタの出力を期待値にコピーしない何度も実行させるなら、権限の許可ルールも関わります。許可ルールは Bash(jq *) のように、コマンド名のあとに * を置く書き方です。Bash(git *) がgitの全サブコマンドを許可するのと同じで、* より前の語が許可の範囲を決めます。広げすぎを避けるなら、Bash(jq *) と Bash(./check.sh *) のようにコマンド単位で書きます。
パイプで渡す入力を絞る
実データが大きいときは、サンプルを選ぶ作業が必要になります。先頭だけを切り出す head -c は、JSONの途中で切れるので向きません。
head -c 300 orders.json | jq .jq: parse error: Unfinished JSON term at EOF at line 6, column 3バイト数で切ると、文字列の途中で壊れます。jqで構造を保ったまま絞るのが安全です。
# 先頭2件だけ
jq '.[0:2]' orders.json
# 全体に出てくるキーの一覧(取りこぼしの確認)
jq -c '[.[] | keys] | add | unique' orders.json
# 明細の qty が何型か(null が混ざっていないか)
jq -r '.[] | .items[] | [.sku, (.qty | type)] | @tsv' orders.json2つ目のキー一覧は、サンプルの選び方に効きます。このデータでは ["customer","id","items","status"] と出ます。選んだ行がこのキーを全部持っているか、持たない行も含んでいるかを見ておけば、欠損キーの行を選び漏らしません。
3つ目の型の確認は、pad の qty が null と出ることで、A-3の罠を一覧から拾えます。目で探すより速く、件数が増えても同じ書き方で使えます。
絞った結果は、そのままClaudeにパイプで渡せます。非対話モードは標準入力を読みます。
jq '.[0:2]' orders.json | claude -p "このJSONの構造を要約して"公式ドキュメントによると、パイプで渡せる標準入力の上限は10MBです。超えると、非ゼロの終了コードでエラー終了します。大きい入力は、ファイルに書き出してパスをプロンプトに書く方法が案内されています。絞る作業は、サイズの制約への対処であると同時に、機密を含む行を渡さないためのふるい分けにもなります。
jqが静かに通してしまう3つの落とし穴
エラーが出ないまま結果が違うケースは、期待出力との照合でしか見つかりません。手元で動かして確かめたものを挙げます。
addは空配列でnullを返す:[] | addは0ではなくnullです。集計の末尾に// 0を付けた理由はここにあります。//はnullだけでなくfalseも置き換える:[true,false,null,0] | map(. // "x")は[true,"x","x",0]になります。真偽値の項目に// デフォルトを付けると、falseの値まで上書きします。数値の0は置き換わりません。.items[]はnullに対してエラー:{"items":null}に[.items[]]をかけると「Cannot iterate over null」で止まります。.items[]?と書けば空配列になりますが、本来エラーにしたい壊れたデータまで見逃す書き方でもあります。
3つ目の直し方は、仕様の判断になります。? を付けて通すのか、エラーで止めるのかは、期待出力に書く内容と合わせて人が決めます。
もう一つ、jq -e は出力が無いとき終了コード4を返します。存在しないIDを検索すると、jq は何も表示せずに終わるだけですが、-e を付ければシェルのスクリプトが「該当なし」を失敗として扱えます。
claude -pの出力をjqで受けるとき
向きを逆にして、Claudeの出力をjqで受ける使い方もあります。公式の非対話モードのページには、--output-format json を付けて結果を取り出す例が載っています。
claude -p "Summarize this project" --output-format json | jq -r '.result'--json-schema を併用したときは、結果が structured_output フィールドに入ります。ただし、Claudeが依頼を断るなどして有効な構造化出力を得られなかった場合、このフィールド自体が応答に含まれません。スクリプトで使う前に、フィールドの有無を確かめます。jq -e '.structured_output' なら、フィールドが無いとき(値がnullになるとき)に終了コード1を返して後続を止められます。
このときも、フィルタの検証は前の節と同じ方法でできます。フィールドがある応答と無い応答を2つのサンプルとして保存し、フィルタを両方に通して、終了コードと出力を期待どおりか確かめます。
まとめ
jqのフィルタをClaude Codeに書かせる検証は、材料を2つに分けて渡すことで成り立ちます。癖のある行を意図して選んだ入力サンプルと、人間が決めた期待出力です。
エラーで止まるフィルタよりも、エラーが出ないのに集計が崩れているフィルタのほうが見つけにくくなります。diffを check.sh のような一つのコマンドに固めておけば、その見つけにくさをClaude自身の反復の中で潰せます。
同じ型は、jq以外にも使えます。入力と期待出力を渡して検証する考え方は、Claude Codeで特性テストを書くで関数単位に展開しています。編集のたびに検証コマンドを自動で走らせたいなら、ESPHomeのyaml設定をhookで検証する構成が同じ仕組みの実例です。データ変換の結果をテストとして残す流れは、dbtのデータテストを足す手順も参考になります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。