スコアは健全になったのに、火を噴く場所が変わらなかった
あるプロジェクトで、Antigravity のアーキテクチャ分析エージェントに技術的負債をスコアリングさせ、上位から順にリファクタリングを進めたことがあります。循環的複雑度の平均は下がり、レポート上の「critical」ファイルは着実に減りました。数字の上では、確かに健全になっていました。
それでも、月末の障害の振り返りで並ぶファイル名は、ほとんど変わっていませんでした。スコアが低い——つまり「きれいな」はずのファイルで、同じような不具合が繰り返し起きていたのです。
このズレを一度きちんと見てからは、静的な複雑度スコアだけでリファクタリングの順番を決めるのをやめました。スコアは必要な指標ですが、それ単体では「どこが実際に壊れるか」を教えてくれません。ここでは、その空振りをどう計測して埋めたかを、実際に使ったコードとともに残しておきます。
なぜ複雑度スコアは的を外すのか
循環的複雑度や認知的複雑度は、ファイルを開いた瞬間の「読みにくさ」をよく捉えます。ネストが深く、分岐が多い関数は、確かに事故りやすい。ここに異論はありません。
問題は、複雑度が「その関数が今後どれだけ触られるか」も「触ったときに何件のモジュールへ波及するか」も見ていないことです。滅多に変更されない複雑な関数は、複雑なまま静かに動き続けます。逆に、一見シンプルでも毎週のように書き換えられ、多くのモジュールから参照されているファイルは、変更のたびに小さな綻びを生みます。障害はそちらで起きます。
つまり実際のリスクは、複雑度そのものよりも「変更頻度(チャーン)」と「被参照数(Fan-in)」の掛け算に強く相関します。この2つは静的解析だけでは出てきません。片方はGit履歴に、もう片方は依存グラフにあります。エージェントに渡すスコアには、この2軸を最初から織り込んでおく必要がありました。
Fan-in をチャーンと掛け合わせてホットスポットを出す
まず、依存グラフから Fan-in(そのモジュールを参照しているモジュール数)を取り、Git のコミット履歴から各ファイルの変更回数を取って、両者を掛け合わせます。狙いは「多くの場所から使われていて、かつ頻繁に書き換わっている」ファイルを浮かび上がらせることです。
// scripts/hotspot-score.ts
// Fan-in(被参照数)× チャーン(変更頻度)でリスクのホットスポットを出す。
// 静的複雑度だけでは沈む「シンプルだがよく壊れる」ファイルを拾うのが目的。
import madge from "madge";
import { execSync } from "child_process";
import { writeFileSync, mkdirSync } from "fs";
interface Hotspot {
file: string;
fanIn: number; // このファイルを参照しているモジュール数
churn: number; // 直近90日のコミットで変更された回数
linesChanged: number; // 同期間の追加+削除行数
hotspotScore: number; // fanIn × churn を対数で圧縮した合成値
}
// 直近90日で「何回コミットに含まれたか」をファイル単位で数える
function collectChurn(sinceDays = 90): Map<string, { commits: number; lines: number }> {
const since = `--since=${sinceDays}.days.ago`;
// --numstat で 追加\t削除\tパス の行が出る。バイナリは - になるので弾く。
const raw = execSync(`git log ${since} --numstat --pretty=format:__COMMIT__`, {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
const churn = new Map<string, { commits: number; lines: number }>();
const seenInCommit = new Set<string>();
for (const line of raw.split("\n")) {
if (line === "__COMMIT__") {
seenInCommit.clear();
continue;
}
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
if (!m) continue;
const added = m[1] === "-" ? 0 : parseInt(m[1], 10);
const removed = m[2] === "-" ? 0 : parseInt(m[2], 10);
const path = m[3];
if (!/\.(ts|tsx)$/.test(path)) continue;
const cur = churn.get(path) ?? { commits: 0, lines: 0 };
// 同一コミット内の重複カウントを避ける
if (!seenInCommit.has(path)) {
cur.commits += 1;
seenInCommit.add(path);
}
cur.lines += added + removed;
churn.set(path, cur);
}
return churn;
}
export async function computeHotspots(srcPath = "./src"): Promise<Hotspot[]> {
const result = await madge(srcPath, {
fileExtensions: ["ts", "tsx"],
excludeRegExp: [/\.test\./, /\.spec\./, /__tests__/],
tsConfig: "./tsconfig.json",
});
const graph = result.obj();
// Fan-in を集計(各依存先が何回参照されているか)
const fanIn = new Map<string, number>();
for (const deps of Object.values(graph)) {
for (const dep of deps) fanIn.set(dep, (fanIn.get(dep) ?? 0) + 1);
}
const churn = collectChurn(90);
const hotspots: Hotspot[] = [];
for (const [file, fi] of fanIn.entries()) {
const c = churn.get(file);
if (!c) continue; // 90日間触られていないなら今回のリスク対象外
// fanIn も churn も裾が長いので対数で圧縮してから掛ける
const score = Math.log2(fi + 1) * Math.log2(c.commits + 1);
hotspots.push({
file,
fanIn: fi,
churn: c.commits,
linesChanged: c.lines,
hotspotScore: Number(score.toFixed(2)),
});
}
hotspots.sort((a, b) => b.hotspotScore - a.hotspotScore);
mkdirSync(".architecture-analysis/scores", { recursive: true });
writeFileSync(
".architecture-analysis/scores/hotspots.json",
JSON.stringify(hotspots.slice(0, 50), null, 2)
);
return hotspots;
}
computeHotspots().then((h) => {
console.log("上位ホットスポット:");
for (const s of h.slice(0, 10)) {
console.log(` ${s.file} fanIn=${s.fanIn} churn=${s.churn} score=${s.hotspotScore}`);
}
});
対数で圧縮しているのは、Fan-in もチャーンも一部の巨大ファイルに極端な値が集中し、掛け算すると桁が暴れるからです。生の積で並べると、たまたま巨大な設定ファイルが常に一位を占めてしまい、順位が実感と合いません。対数を挟むと、上位10件が「確かにここは毎回怖い」という肌感覚と一致するようになりました。
git log --numstat を1回だけ呼び、コミット境界を __COMMIT__ マーカーで区切って重複カウントを避けているのも実務上の勘所です。ファイル単位に git log を回すと、数千ファイルのプロジェクトで分単位の時間がかかります。1回のログ出力をパースする方式なら数秒で終わります。
スコアと実際の障害を突き合わせる
ホットスポットが出せても、それが本当に障害と相関しているかは別問題です。ここを検証しないまま導入すると、また別の「もっともらしいが的外れなスコア」を増やすだけになります。そこで、過去のインシデントで実際に修正が入ったファイルと、スコア上位を突き合わせる照合を挟みました。
インシデントの記録源は何でも構いません。私たちの場合は、障害対応のコミットに fix: プレフィックスと incident/<id> のトレーラを付ける運用にしていたので、そこから修正ファイルを引けます。
// scripts/reconcile-score-vs-incidents.ts
// スコア上位N件が、実際のインシデント修正ファイルをどれだけ捕捉できているかを測る。
// 静的複雑度ランキングとホットスポットランキングを同じ土俵で比較する。
import { execSync } from "child_process";
import { readFileSync } from "fs";
// インシデント対応コミットが触ったファイル集合を取り出す
function incidentFiles(sinceDays = 180): Set<string> {
const raw = execSync(
`git log --since=${sinceDays}.days.ago --grep='incident/' -i --name-only --pretty=format:__C__`,
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }
);
const files = new Set<string>();
for (const line of raw.split("\n")) {
if (line === "__C__" || line.trim() === "") continue;
if (/\.(ts|tsx)$/.test(line)) files.add(line);
}
return files;
}
// あるランキング(ファイル配列・上位が先頭)が、
// インシデント集合をどれだけ上位N件で捕捉できるかを Recall@N で返す
function recallAtN(ranking: string[], truth: Set<string>, n: number): number {
const topN = new Set(ranking.slice(0, n));
let hit = 0;
for (const f of truth) if (topN.has(f)) hit++;
return truth.size === 0 ? 0 : Number((hit / truth.size).toFixed(3));
}
const truth = incidentFiles(180);
const hotspots: { file: string }[] = JSON.parse(
readFileSync(".architecture-analysis/scores/hotspots.json", "utf8")
);
const complexity: { filePath: string }[] = JSON.parse(
readFileSync(".architecture-analysis/scores/debt-scores.json", "utf8")
);
const hotspotRanking = hotspots.map((h) => h.file);
const complexityRanking = complexity.map((c) => c.filePath);
for (const n of [10, 20, 30]) {
console.log(
`Recall@${n} 複雑度=${recallAtN(complexityRanking, truth, n)} ` +
`ホットスポット=${recallAtN(hotspotRanking, truth, n)}`
);
}
この照合を最初に回したとき、複雑度ランキングの Recall@20 は 0.2 台、つまり実際に障害が出たファイルのうち複雑度で上位20件に入っていたのは全体の 20% 前後にすぎませんでした。一方、Fan-in×チャーンのホットスポットは同じ Recall@20 で 0.5 を超え、捕捉率にして 50% 以上、実に 2 倍以上の差がつきました。エージェントに「まずここを直せ」と言わせるなら、後者を主軸に据えるべきだと、この数字が決めてくれました。
大事なのは、この Recall を一度きりの検証で終わらせないことです。運用しながら四半期ごとに測り直すと、リファクタリングが効いてホットスポットの捕捉率が下がる(=直した場所で障害が減る)のか、それとも別の場所へ火種が移っただけなのかが見えます。
| 指標 | 見ているもの | 単体の弱点 | 運用での役割 |
| 循環的/認知的複雑度 | 読みにくさ・分岐の多さ | 変更頻度と波及を見ない | 着手時の難易度見積もり |
| Fan-in | 被参照数(波及の広さ) | 触られない安定モジュールも上位に出る | 影響範囲の重み付け |
| チャーン | 直近の変更頻度 | 新規大量投入で一時的に跳ねる | 「生きている」負債の絞り込み |
| Fan-in×チャーン | 波及の広い活発なファイル | 障害履歴とは独立 | 返済順位の主軸 |
| インシデント照合(Recall@N) | スコアが実障害を捕捉できたか | 過去の記録品質に依存 | スコア自体の妥当性検証 |
エージェントの優先順位付けを障害履歴で補正する
Antigravity のアーキテクチャ分析エージェントに AGENTS.md でスコアリングを任せる場合、静的スコアだけを渡すと、エージェントは素直に「複雑度が高い順」に提案を組み立てます。ここに、いま作ったホットスポットとインシデント照合の結果を追加の文脈として渡すと、返済順の提案が実感に近づきます。
具体的には、エージェントへの指示に「複雑度スコアではなくホットスポットスコアを主キーとし、複雑度は同点時の第2キーとして扱う」「Recall@20 でホットスポット側が複雑度側を下回った四半期は、照合ロジックの見直しを先に提案する」という2点を明記しました。
# Architecture Analyst Agent(抜粋・改訂版)
## Prioritization Rules
- リファクタリング順位の主キーは hotspots.json の hotspotScore とする。
- 複雑度(debt-scores.json)は同点時の第2キーに留める。単独では順位を決めない。
- 提案前に reconcile レポートを読み、ホットスポット側 Recall@20 が
複雑度側を下回っていれば、コード修正より先に計測ロジックの見直しを提案する。
- 各提案には「fanIn / churn / 直近インシデント有無」を根拠として併記する。
## Output
- 返済ロードマップ → .architecture-analysis/reports/refactor-roadmap.md
- 各項目に推定工数と、触った場合の依存波及ファイル一覧を添える
この改訂で変わったのは、エージェントが「きれいにすると気持ちいいファイル」ではなく「直すと障害が減るファイル」を先頭に置くようになったことです。スコアは同じツールが出していても、何を主キーにするかで提案の質は大きく変わります。
運用してみて分かった注意点
本番運用に載せてから慌てないために、あらかじめ回避しておきたい落とし穴がいくつかあります。まず、チャーンは諸刃の剣です。新機能を一気に投入した直後は、その領域のチャーンが跳ね上がり、ホットスポット上位を占めます。しかしそれは「開発が活発」なだけで「壊れやすい」わけではありません。私は投入直後の2週間はその領域に一時的な減衰係数をかけ、落ち着いてから通常の重みに戻すようにしています。これをやらないと、エージェントが「今まさに作っている最中のコードをリファクタせよ」と提案してきて、現場と噛み合いません。
インシデント照合の精度は、過去のコミット運用の質にそのまま縛られます。incident/ トレーラを付け始める前の障害は拾えません。導入初期は Recall の絶対値ではなく、四半期ごとの推移を見る方が安全です。記録が積み上がるほど照合は効くようになります。
そして、スコアはあくまで会話の出発点だと考えています。ホットスポット上位のファイルを開いて、チームで「これは確かに毎回怖い」と頷けるかを最後に確かめる。数字が現場の肌感覚と一致しないなら、疑うべきは現場ではなく計測の方です。私自身、2014年から個人開発で複数のサービスを並行して回していると、つい「レポートの赤い項目」から手をつけたくなりますが、赤の付け方そのものを時々点検するようにしています。
次の一手としては、いま手元のプロジェクトで git log --numstat を1回流し、Fan-in の高いファイルと直近90日のチャーンを掛けた上位10件を書き出してみてください。その10件が、直近の障害振り返りで挙がったファイルとどれだけ重なるか。その重なり具合が、あなたのスコアが実リスクを捉えられているかの最初の答えになります。