Claude Media
Claude Codeでmadgeの循環参照を解消する手順 — 図の読み方と切り方

Claude Codeでmadgeの循環参照を解消する手順 — 図の読み方と切り方

madge --circularの出力をClaude Codeに渡し、型の切り出しとbarrel分解で循環importを1件ずつ解消する手順です。--imageの図の読み方とskipTypeImportsの落とし穴も扱います。

Claude Codeでmadgeの循環参照を解消する手順 — 図の読み方と切り方

循環参照は、検出するより直す順番で失敗します。madgeが循環を指摘したら、Claude Codeには「循環を1件選び、importを1本だけ切り、madgeをもう一度回して件数が減ったことを確かめる」という1サイクルを繰り返させます。全件を一度に直させると、どの変更が効いたのか、どの変更が別の循環を生んだのかが追えなくなります。

madgeはJavaScriptとTypeScriptのモジュール依存を調べるnpmパッケージです。--circular で循環を一覧にし、--image で図にできます。この記事では、出力の読み方から、Claude Codeに渡す指示、解消パターン2種類(共通部分の切り出しとbarrelの分解)、検出から外してよい循環と外してはいけない循環までを順に書きます。

madgeで循環参照を拾う設定

madgeのインストールはグローバルでも npx 経由でも構いません。READMEのインストール手順はグローバル版です。

npm -g install madge

図を出力する場合だけGraphvizが必要です。READMEでは、Mac OS Xは brew install graphviz || port install graphviz、Ubuntuは apt-get install graphviz とされています。一覧表示だけならGraphvizは要りません。

循環の検出は次の1行です。

madge --circular --extensions ts src/index.ts

--extensions を付けない場合、READMEの設定表では対象の拡張子は js だけです。TypeScriptのプロジェクトで拡張子を指定せずに回すと、.ts ファイルが対象に入りません。「循環0件」と出たときは、まず拡張子を疑います。

エイリアス(@/ など)を使うプロジェクトでは、tsconfigの場所をmadgeに教える必要があります。設定ファイル .madgerc に tsConfig を書く形が、READMEの設定表に沿った書き方です。

{
  "fileExtensions": ["ts", "tsx"],
  "tsConfig": "tsconfig.json",
  "detectiveOptions": {
    "ts": { "skipTypeImports": false }
  }
}

設定ファイルは、プロジェクト直下の .madgerc か package.json の madge キーに置けます。READMEには、JavaScriptとTypeScriptが混在するなら webpackConfig と tsConfig の両方を渡すこと、とあります。

--imageの図をどう読むか

循環だけを絵にするには、--circular と --image を併用します。READMEの例は次のとおりです。

madge --circular --image graph.svg --extensions ts src/index.ts

READMEの凡例では、図の色は3種類です。

数字

madgeの図の色の意味

  • 青

    依存あり

    他のモジュールをimportしているノード

  • 緑

    依存なし

    何もimportしていない末端のノード

  • 赤

    循環

    既定の色コードは #ff6c60

READMEの説明と、設定表の既定の色コードより

見るべきなのは赤のノードどうしを結ぶ矢印です。2つのノードが互いに矢印で結ばれていれば直接の相互import、3つ以上で輪になっていれば間接の循環です。矢印の向きの凡例はREADMEに載っていないので、どちらがimportする側かは、該当ファイルを開いて確かめてください。

図が読みにくいときの対処もREADMEにあります。既定のレイアウトは dot ですが、neato、fdp、sfdp、twopi、circo に切り替えられます。大きなグラフには sfdp、循環の構造を見たいだけなら circo が候補になります。向きは rankdir で変えられ、LR(左から右)が設定表の既定です。

図の出力でつまずいたとき

READMEのFAQに載っている2件です。

  • Error: write EPIPE が出るときは、Graphvizが入っていません。Windowsではインストーラーが PATH に追加しないので、gvpr.exe のあるフォルダ(通常は %Graphviz_folder%/bin)を手で追加します
  • sfdp で Graphviz not built with triangulation library が出るときは、HomebrewのGraphvizにGTSが含まれていません。brew uninstall graphviz、brew install gts、brew install graphviz の順に入れ直します

Claude Codeに渡す1サイクルの指示

Claude Codeには、図より先にテキストの一覧を渡します。画像ではなく文字の方が、Claudeがファイルを開いて読むときの手がかりになるからです。

まず、madgeを実行する許可を絞って出しておきます。.claude/settings.json に許可ルールを書く形です。

{
  "permissions": {
    "allow": [
      "Bash(npx madge *)",
      "Bash(npx tsc --noEmit)"
    ]
  }
}

permissionsのドキュメントでは、Bash(npm run *) のように * をサブコマンドの後ろに置くのが作法とされています。Bash(npx madge *) はこの形に沿っています。許可の設計の考え方は、ffmpegの権限設計と同じです。実行してよい検査コマンドだけを通し、書き込みを伴うコマンドは通しません。

指示文は、1サイクルの範囲を言葉で縛ります。

npx madge --circular --extensions ts src/index.ts を実行して、循環の一覧を出して。
その中から最も短い循環を1つだけ選び、次の順で進めて。
1. 循環に関わるファイルを読み、どのimportが循環を作っているか説明する(まだ編集しない)
2. 切るimportを1本だけ決め、理由を書く
3. 編集して npx tsc --noEmit と npx madge --circular を再実行する
4. 循環の件数が減ったことと、型エラーが増えていないことを報告する

「最も短い循環」から始めるのは、直接の相互importが最も原因を特定しやすいためです。手順1で編集を止めておくと(Claude CodeのPlan modeで回す形でも同じ効果があります)、Claudeが原因を読み違えたまま直し始めることを防げます。公式のワークフロー集でも、リファクタリングは「小さく、テスト可能な単位で進める」ことが勧められています。手順の流れは、リファクタリングを安全に進める手順の1サイクルと同じ考え方です。

1サイクルが終わるたびにコミットを切ると、次のサイクルで循環が増えたときに、前のコミットへ戻せます。件数が減らなかったサイクルは、コミットせずに捨てます。

切る前に、そのモジュールの利用者を数える

共通部分を別ファイルへ移す前に、移し先の候補を誰が使っているかを確かめます。READMEには、指定したモジュールに依存するモジュールを出す --depends があります。

madge --depends order.ts --extensions ts src/index.ts

利用者が1つか2つなら、そのimportを直接書き換えるだけで済みます。多数のファイルから使われているモジュールを動かすと、1サイクルの差分が一気に膨らみます。その場合は、移す対象を型だけに絞り、実装は元のファイルに残してください。

解消パターン1: 共通部分を第三のモジュールへ抜く

最も基本の型です。AとBが互いにimportしているとき、双方が必要としている部分を第三のモジュールCに移し、AとBはCだけを見るようにします。

次のようなコードを例にします(説明用の最小例です)。

// user.ts
import { Order } from "./order";
export interface User { id: string; orders: Order[] }
export function makeUser(id: string): User { return { id, orders: [] }; }
 
// order.ts
import { User, makeUser } from "./user";
export interface Order { id: string; owner: User }
export function makeOrder(id: string): Order { return { id, owner: makeUser("u1") }; }

user.ts が order.ts の型を、order.ts が user.ts の型と関数を使っています。madgeは user.ts と order.ts の相互参照を循環として指摘します。

共通しているのは User と Order の型です。これを types.ts に移します。

// types.ts
export interface User { id: string; orders: Order[] }
export interface Order { id: string; owner: User }
 
// user.ts
import type { User } from "./types";
export function makeUser(id: string): User { return { id, orders: [] }; }
 
// order.ts
import type { Order } from "./types";
import { makeUser } from "./user";
export function makeOrder(id: string): Order { return { id, owner: makeUser("u1") }; }

types.ts は何もimportしない末端です。order.ts から user.ts への一方向だけが残り、循環は消えます。型同士が互いを参照する場合は、同じファイルにまとめれば済みます。上の User と Order がその例です。

Claudeに頼むときは、「移すのは型だけにして、関数は動かさない」と書き添えます。関数の実装まで第三のモジュールへ動かすと、差分が大きくなり、サイクルの検証が重くなるためです。

解消パターン2: barrelを分解する

barrelは、フォルダ内の全モジュールを再exportする index.ts のことです。便利な反面、フォルダ内のあるファイルがbarrel経由で別のファイルをimportすると、そのbarrelを通して自分自身に戻る輪ができます。

// features/index.ts (barrel)
export * from "./cart";
export * from "./checkout";
 
// features/cart.ts
import { calcTax } from "./index";   // barrel経由で checkout.ts を巻き込む

この形だと、cart.ts → index.ts → cart.ts の輪がmadgeの一覧に出ます。直し方は、フォルダ内からのimportをbarrelではなく直接のファイルに向けることです。

// features/cart.ts
import { calcTax } from "./checkout"; // 必要なファイルを直接指す

barrelそのものは、フォルダの外から使う入口として残して構いません。禁止するのは、フォルダの内側からbarrelを見ることだけです。Claudeへの指示は「features/ 内のimportで ./index を指しているものを、直接のファイルに置き換える」と具体的に書きます。「barrelを直して」では範囲が広すぎます。

型だけのimportは検出から外してよいか

型のためだけのimport(import type)は、コンパイル後のJavaScriptには残りません。そのため、実行時の循環にはならず、検出から外したい場面があります。READMEのFAQでは、TypeScriptの型importを無視する設定が紹介されています。

{
  "detectiveOptions": {
    "ts": { "skipTypeImports": true }
  }
}

これを入れると、型のimportで作られた循環はmadgeの一覧から消えます。ただし、これは循環を解消したのではなく、見えなくしただけです。型の相互参照が残っていると、後から片方に値のimportが足された瞬間、本物の実行時の循環になります。

使い分けの目安は次のとおりです。

状況skipTypeImports理由
実行時の循環を優先して潰したいskipTypeImportstrue理由型だけの相互参照を一覧から外し、対処すべき循環に集中できる
循環を原則ゼロにしたいskipTypeImportsfalse理由型の循環も残らず出るので、パターン1で types.ts に集められる
動的 import() が多いskipTypeImportsskipAsyncImports も検討理由READMEでは ts と tsx の両方に指定する形

どちらを選んでも、Claudeに「設定で消す」ことを許す場合は、その設定変更だけを独立したコミットにします。コードの解消と設定の無視が混ざると、あとで何が解決済みなのか分からなくなります。

一覧が空になったときの確認

Claudeが「循環は0件です」と報告しても、madgeが対象ファイルを読めていない可能性があります。READMEのFAQには、読めなかったファイルは --warning で確認できる、とあります。

madge --circular --warning --extensions ts,tsx src/index.ts

0件のときに確かめる点は3つです。

  • --extensions に実際の拡張子(ts、tsx)が入っているか
  • 解決できないエイリアスの警告が --warning に出ていないか
  • JavaScriptとTypeScriptが混在するなら、webpackConfig と tsConfig の両方を渡しているか

この確認は、Claudeの指示にも1行入れておくと安全です。「0件のときは --warning を付けて再実行し、スキップされたファイルがないことを確認してから報告する」と書きます。

まとめ

madgeの出力は、循環の検出には十分ですが、直す順番までは教えてくれません。Claude Codeには、最短の循環を1件だけ選ばせ、importを1本だけ切らせて、検証してから次へ進ませます。切り方の第一候補は、共通の型を末端のモジュールへ抜くこと。barrelが原因なら、フォルダ内のimportを直接のファイルに向けること。skipTypeImports は、解消の代わりではなく、優先順位をつけるときの道具として使います。

参照した一次ソース

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

  1. github.com/pahen/madge
  2. code.claude.com/docs/en/common-workflows
  3. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn