Claude Codeで技術負債のホットスポットを洗い出す — 変更回数×行数で直す順番を決める
git logの変更回数に現在の行数を掛けて、先に直すファイルを数字で絞る手順です。集計スクリプトと、Claude Codeに読ませて直す順番を決める呼び出し方をまとめます。
技術負債の「どこから直すか」は、変更が集中していて、しかも大きいファイルから決めると外しにくくなります。git logの変更回数に現在の行数を掛けるだけで、そのリストが作れます。集計はシェルで済ませ、Claude Codeには上位のファイルを読ませて「分割・テスト追加・放置」の判断を付けさせます。
この記事では、集計スクリプトと、結果をClaude Codeに渡すclaude -pの呼び出し方を、小さな検証用リポジトリの実行結果つきで示します。
変更回数×行数が示すもの
ホットスポットは、「よく変更される」と「大きい」が重なったファイルです。どちらか一方だけでは、直す優先度の根拠として弱くなります。
- よく変更されるが小さい: 直しやすく、害も小さい
- 大きいが変更されない: 読みにくくても、触る機会が少なく、負債の利子を払っていない
- よく変更されて大きい: 変更のたびに読む量が多く、壊すリスクも高い。ここが先
行数は複雑さの代用品にすぎません。循環的複雑度のような指標を別途出せるなら差し替えられますが、どのリポジトリでも手元ですぐ取れるのは行数です。この記事では変更したコミット数 × 現在の行数をスコアにします。
検証用に作った42コミットの小さなリポジトリで試すと、次の結果になりました。scoreの高い順です。
| file | commits | loc | score |
|---|---|---|---|
| src/api/orders.ts | commits19 | loc924 | score17556 |
| src/api/billing.ts | commits13 | loc714 | score9282 |
| src/web/Header.tsx | commits20 | loc142 | score2840 |
| src/api/legacy_report.ts | commits1 | loc2501 | score2501 |
| src/web/utils.ts | commits6 | loc306 | score1836 |
変更回数だけで並べるとHeader.tsxが首位になります。掛け算にすると、大きくて頻繁に触られるorders.tsが上に来ます。2,500行あっても1回しか触られていないlegacy_report.tsは4位で、「今すぐ直す対象ではない」位置に収まります。
git logで変更回数と変更行数を集める
材料はgit log --numstatが出す、コミットごとの追加行数・削除行数・パスの3列です。--numstatは--statの機械向け版で、バイナリファイルは数字の代わりに-が2つ出ます。
git log --since="6 months ago" --no-merges --no-renames \
--numstat --format=各オプションの役割は次のとおりです。
--since="6 months ago": 指定日より新しいコミットだけを対象にする。古い変更で現在の状態を見誤らないよう期間を切る--no-merges: 親が複数あるマージコミットを除く--no-renames: 名前変更の検出を切る。有効だと{old => new}形式のパスが混じり、集計が面倒になる--format=: コミットメッセージの出力を空にして、numstatの行だけを残す
--no-renamesにすると、移動したファイルは「旧パスの削除」と「新パスの追加」に分かれます。移動直後のファイルは履歴が短く見えるので、大きな移動があった直後は注意が要ります。
生成物は最初から除きます。package-lock.jsonのようなロックファイルは、依存を更新するたびに変更回数が積み上がり、上位を占領します。パスの除外は、パス指定に':(exclude)*package-lock.json' ':(exclude)*.lock'のように具体名で書きます。*lock*のような広いパターンは、clock.tsやblock.tsxまで黙って落とします。
現在の行数を掛けてスコアにする
行数は集計時点の作業ツリーで数えます。すでに削除されたファイルは履歴に残っていても対象外にします。スクリプトにすると次のとおりです。
#!/usr/bin/env bash
# 使い方: ./hotspots.sh [期間(既定 6 months ago)] [件数(既定 10)]
since="${1:-6 months ago}"; top="${2:-10}"
git log --since="$since" --no-merges --no-renames --numstat --format= \
-- . ':(exclude)*package-lock.json' ':(exclude)*.lock' ':(exclude)dist' \
| awk -F'\t' 'NF==3 && $1!="-" { c[$3]++; ch[$3]+=$1+$2 }
END { for (f in c) printf "%s\t%d\t%d\n", f, c[f], ch[f] }' \
| while IFS=$'\t' read -r f commits changed; do
[ -f "$f" ] || continue # 削除・移動済みのファイルは対象外
loc=$(wc -l < "$f" | tr -d ' ')
printf '%d\t%d\t%d\t%d\t%s\n' "$((commits * loc))" "$commits" "$loc" "$changed" "$f"
done \
| sort -t$'\t' -k1,1nr | head -n "$top" \
| awk -F'\t' 'BEGIN { print "score\tcommits\tloc\tchanged\tfile" } { print }'awkの部分は、numstatの行(タブ区切りで3列)だけを拾い、パスごとにコミット数と変更行数(追加+削除)を足します。バイナリの-の行は$1!="-"で落とします。changedは参考値で、スコアには使いません。
./hotspots.sh "6 months ago" 5 | column -t -s$'\t'score commits loc changed file
17556 19 924 19 src/api/orders.ts
9282 13 714 13 src/api/billing.ts
2840 20 142 20 src/web/Header.tsx
2501 1 2501 1 src/api/legacy_report.ts
1836 6 306 6 src/web/utils.ts検証リポジトリは1コミットあたり1行ずつ足しているだけなのでchangedとcommitsが同じ値になっています。実際のリポジトリでは、1コミットで数百行を書き換えるファイルと、1行だけ直し続けるファイルでこの値が分かれます。
上位のファイルをどう読むか
数字は「どこを読むか」を決めるもので、「何をするか」までは決めません。上位に出た理由は、おおむね次のどれかです。
| 型 | 例 | 読み方 | 次の手 |
|---|---|---|---|
| 大きく、よく直される | 例orders.ts | 読み方責務が混ざり、変更のたびに別の場所が壊れている疑い | 次の手分割の候補。先にテストを足す |
| 小さく、よく触られる | 例Header.tsx | 読み方設定やスタイルの集積場所。負債より仕様変更の窓口 | 次の手放置か、設定の外出し |
| 大きく、ほぼ触られない | 例legacy_report.ts | 読み方触らなければ害は小さい | 次の手直さない。触る予定ができた時点で再評価 |
| 機械的な変更で多い | 例ロックファイル、自動整形 | 読み方負債の指標ではない | 次の手除外パターンに追加 |
変更回数の中身は、コミットメッセージを見ると分かります。バグ修正で増えた変更と、機能追加で増えた変更では、直すべき理由が違うからです。バグ修正に絞るなら、--grepでコミットメッセージを正規表現で絞れます。
git log --since="6 months ago" --no-merges --grep='^fix' \
--name-only --format= | sort | uniq -c | sort -rn | head -5--grepの絞り込みが成り立つのは、チームがコミットメッセージの先頭にfix:などを付けている場合だけです。付けていないなら、この絞り込みは使えません。検証リポジトリでは、全期間の変更回数が上位のorders.tsは、fix絞り込みでも19件で首位のままでした。Header.tsxは20件から10件に減り、半数がstyle:のコミットで、見た目の調整で多く触られていたと分かります。
Claude Codeに読ませて直す順番を決める
ここからがClaude Codeの出番です。集計を終えた表をそのまま標準入力に流し、上位のファイルを読ませます。Claude Codeのドキュメントにも、データをパイプで渡してclaude -pに説明させる使い方が載っています。
./hotspots.sh "6 months ago" 10 \
| claude -p "これは直近6か月の変更コミット数と現在の行数を掛けた上位10ファイルの表です。
各ファイルを読み、変更が多い理由、分割・テスト追加・放置のどれから手を付けるか、
理由を1行ずつ書いてください。最後に着手順を並べてください。" \
--allowedTools "Read,Grep,Bash(git log *)" \
--permission-mode dontAsk権限の指定が肝です。--allowedToolsで挙げたツールは確認なしで動きます。Bash(git log *)は、末尾の *が前方一致なので、git logで始まるコマンドだけを許可します。Editや書き込み系のBashは挙げていないので、分析中にファイルが書き換わることはありません。
--permission-mode dontAskは、確認が必要になる呼び出しをすべて拒否します。人が待っていない実行で止まらないようにするためのモードで、許可リストにあるものだけが通ります。分析のように読み取りだけで済む作業に向きます。
結果をスクリプトで扱いたいときは、--output-format jsonと--json-schemaを組み合わせると、structured_outputに指定した形のJSONが入ります。jq '.structured_output'で取り出せます。
./hotspots.sh "6 months ago" 10 \
| claude -p "上位ファイルを読み、着手順に並べてください" \
--allowedTools "Read,Grep,Bash(git log *)" \
--permission-mode dontAsk \
--output-format json \
--json-schema '{"type":"object","properties":{"plan":{"type":"array","items":{"type":"object","properties":{"file":{"type":"string"},"action":{"type":"string"},"reason":{"type":"string"}},"required":["file","action","reason"]}}},"required":["plan"]}' \
| jq '.structured_output'ドキュメントによると、有効な構造化出力が得られなかった実行(Claudeが依頼を断った場合など)ではstructured_outputが付きません。スクリプトに組み込むときは、フィールドの有無を先に確かめます。
CIやスケジュール実行に載せるなら、--bareを足すと、ホストのhooks・プラグイン・自動メモリ・CLAUDE.mdを読み込まずに起動します。マシンごとの設定差で結果が変わるのを避けられます。--bareではサブスクリプションのログインを使わないので、ANTHROPIC_API_KEYを環境変数で渡します。
Claudeの答えを鵜呑みにしない点
Claude Codeが返す「分割すべき」は、ファイルを読んだ印象に基づく見立てです。次の3点を人が確かめてから、チケットに起こします。
- 変更が多い理由が本当にそのファイルの構造か。仕様変更が集中しただけなら、直しても減らない
- 分割の手前にテストがあるか。ない場合、最初の一手は分割でなくテストの追加になる
- 提案された分割が、
git logで並んで変更されている別ファイルと整合しているか
数字が実態とずれる場面
集計の結果が妙なときは、次の原因を疑います。
集計が実態とずれる5つの原因
浅いクローンで履歴が欠ける
git logが見られるのは、取得済みの履歴だけです。Claude CodeのGitHub Actionsのドキュメントにある例は、actions/checkoutにfetch-depth: 1を指定しています。このまま集計すると、直近1コミットしか見えず、ホットスポットは出ません。CIで集計するなら、履歴を十分に取得する設定に変えます。一括整形やリネームで順位が動く
フォーマッタを全体にかけた1コミットは、全ファイルの変更回数を1ずつ上げます。たった1回でも順位は動きます。スコアは変更回数に行数を掛けるので、増える量は行数の大きいファイルほど多いからです。上の表の例なら、legacy_report.tsは(1+1)×2501=5002になり、Header.tsxの(20+1)×142=2982を抜いて4位から上がります。整形コミットは
--invert-grepで件名を指定するか、ハッシュを指定して除きます。--sinceの起点をその後ろにずらす手もあります。期間の取り方で順位が入れ替わる
半年と2年では上位が変わります。短いと最近の開発を、長いと蓄積した負債を拾います。両方を並べて、どちらでも上位に出るファイルから手を付けると外しにくくなります。
行数は複雑さではない
設定の羅列で長いだけのファイルも、短くて分岐だらけのファイルも、行数では区別できません。上位の数件は、実際に読んで判断します。そこをClaude Codeに任せるのが、前の節の呼び出しです。
新しいファイルは過小評価される
作られて間もないファイルは、変更回数が少なくて当然です。ここ1か月に作られた大きなファイルは、スコアに表れない候補として別に眺めます。
Claudeのスキルとの使い分け
公式のengineeringプラグインのtech-debtスキルは、負債の分類と点数付けの枠組みを与えてくれます。詳しくはClaudeのtech-debtスキルで技術的負債を棚卸しし優先順位を付けるにあります。スキルが決めるのは「影響・リスク・工数」の見積りで、その材料は呼び出し側が用意する形です。
この記事の集計は、その材料を用意する側にあたります。計測で上位に出たファイルをスキルへの入力に渡せば、見積りの根拠が「git logの実測」になります。逆に、スキルだけで回すと、リポジトリの見える範囲からClaudeが推測する割合が増えます。
集計結果を担当者の割り当てにつなげるなら、Claude CodeでCODEOWNERSを作るが同じgit logの考え方を使っています。ホットスポットの変更が増えた時期を絞りたいときは、git bisectで回帰の入った変更を特定する手順が使えます。
まとめ
直す順番を決めるときに、必要な数字は変更したコミット数 × 現在の行数の1つで足ります。git logの--numstatで集計し、ロックファイルなどを除外して、作業ツリーの行数と掛けるだけです。
上位が出たら、人が全部を読む前にclaude -pへ表を渡し、読み取り専用の権限で上位ファイルの理由と着手順を作らせます。出てきた見立ては、変更が多い理由・テストの有無・同時に変わるファイルの3点を確かめてから、実際の作業に起こします。数字が怪しいときは、浅いクローンと一括整形のコミットから疑ってください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。