graphifyのコードグラフを映える図にしてみた
AIコードレビューの仕組みを勉強するついでに、Alibaba が公開しているレビューツールのソースコードを、関数どうしの呼び出しグラフにして描いてみました。1,291個の関数と2,516本の呼び出しが、パッケージごとの島に分かれて並びます。

描く途中で、私が7月から使っているコードグラフのツール graphify でも同じリポジトリを処理してみました。すると、島と島をつなぐ線がほぼ1本もありません。原因はツールの版でした。手元で固定していた 0.9.6 は、Go のパッケージをまたぐ呼び出しをほぼ取れていませんでした。最新の 0.9.106 で取り直すと、Go 公式の解析ツールが出す呼び出しのうち77%が取れていました。
以下は、コードを呼び出しグラフにして見映えのする図に仕上げる手順と、そのグラフを Go 公式の結果と突き合わせた記録です。
描いたもの
対象は alibaba/open-code-review です。2026年5月に公開された Alibaba のコードレビューツールで、リポジトリの説明には「Alibaba の規模で使い込まれた」とあり、決まった手順で動く処理と LLM エージェントを組み合わせた作りです。Go で書かれています。ライセンスは Apache-2.0 なので、図にして載せても問題ありません。
| 項目 | 値 |
|---|---|
| 対象 | open-code-review、commit db5db6b(2026-11-11) |
| 範囲 | cmd/・internal/・acp/ の .go。テストと testdata は除外 |
| graphify | 0.9.6(手元で固定していた版)と 0.9.106(11/12 時点の最新) |
| 比較相手 | Go 公式の golang.org/x/tools/cmd/callgraph v0.52.0 |
| 描画 | Python の networkx 3.4.2 + matplotlib |
このリポジトリを選んだのは、AI コードレビューの仕組みを勉強するために読んでいたからです。読むついでに、コード全体の構造を図にしてみました。
graphify でシンボルグラフを作る
graphify は、tree-sitter でソースコードを構文木にし、関数・クラス・呼び出し関係をグラフにして JSON に書き出すツールです。ドキュメントや画像を LLM で読み込む機能もありますが、update はコードの構文解析だけで動き、LLM を呼びません。
pip install graphifyy # PyPI 上の名前は y が2つ
git clone --depth 1 https://github.com/alibaba/open-code-review
cd open-code-review
graphify update . # → graphify-out/graph.json
graph.json には、関数やファイルなどのノードと、contains(ファイルが関数を含む)・calls(呼び出し)・imports_from などのエッジが入っています。呼び出しグラフにするなら、calls のエッジのうち確度が EXTRACTED(構文から直接取れたもの)のものだけを残します。0.9.106 では、推測で張った INFERRED の呼び出しも出力されます。
import json, networkx as nx
d = json.load(open("graphify-out/graph.json"))
def is_src(f):
return f.endswith(".go") and not f.endswith("_test.go") and f.split("/")[0] in ("cmd", "internal", "acp")
nodes = {n["id"]: n for n in d["nodes"] if is_src(n.get("source_file") or "")}
G = nx.DiGraph()
for e in d["links"]:
if e["relation"] == "calls" and e["confidence"] == "EXTRACTED" \
and e["source"] in nodes and e["target"] in nodes:
G.add_edge(e["source"], e["target"])
手元の 0.9.6 でこのグラフを描くと、パッケージごとの島は出るのに、島どうしをつなぐ線がほとんどありませんでした。CLI からエージェントを呼び、エージェントから LLM のクライアントを呼ぶツールなので、パッケージ間の呼び出しが無いはずはありません。ただ、graphify の出力だけを見ていても、本当にそういう構造なのか、graphify が取りこぼしているのかは区別できません。そこで、別の方法で作った呼び出しグラフと比べてみることにしました。
Go 公式の callgraph と比べる
比較の相手には、Go 公式の callgraph を使います。これを選んだのは、graphify とは名前の解決のしかたが違うからです。
graphify(tree-sitter)が見ているのは構文だけです。llm.CountTokens(...) という字面から、llm という名前の何かの CountTokens を呼んでいることは分かります。ただ、その llm がどのパッケージを指すかは、import 文やファイルの場所から推測して結びつけるしかありません。s.save() のようなメソッド呼び出しでは s の型が分からないので、同じ名前のメソッドが複数あるとどれなのか決められません。
callgraph は、Go のコンパイラと同じ型チェッカー(go/types)でプログラム全体を読み込みます。識別子の1つ1つが、どのパッケージのどの宣言を指すのかを確定させてから呼び出しを数えます。コンパイルが通るコードなら、関数名やメソッド名で呼んでいる呼び出しの行き先は1つに決まり、推測が入りません。ただし、行き先が実行時まで決まらない呼び出しもあります。インターフェース経由の呼び出しや、関数を値として渡して呼ぶ場合です。ここは callgraph でも「入りうる先」までしか出せません。
go install golang.org/x/tools/cmd/callgraph@latest
M=github.com/alibaba/open-code-review
callgraph -algo=vta -format='{{.Caller}} {{.Callee}}' ./cmd/... ./internal/... \
| awk -F'\t' -v m="$M" 'index($1,m) && index($2,m)' | sort -u > cg_vta.tsv
-algo には精度の違う方式がいくつかあります。
static: 関数名で確定する呼び出しだけを取る。インターフェース経由の呼び出しは含まないvta: インターフェース経由の呼び出しを、実際に入りうる型まで絞って張る
図には vta を使いました。
インターフェース経由の呼び出しも描きたかったからです。ただし vta は「呼ばれる可能性がある先」を全部張るので、実行時の呼び出しより多めに出ます。graphify との比較では、名前で確定する static の呼び出し2,123本と比べました。
出力の1行は (*pkg.Type).Method や pkg.Func$1(関数の中で定義した無名関数)の形をしています。無名関数は親の関数にまとめます。パッケージと関数名に分けてノードにします。
import re
M = "github.com/alibaba/open-code-review/"
def norm(s):
s = re.sub(r"\$\d+(\$\d+)*$", "", s).replace(M, "") # 無名関数は親に寄せる
m = re.match(r"\(\*?([\w/.-]+)\.(\w+)\)\.(\w+)", s) # (*pkg.Type).Method
if m: return m[1], f"{m[2]}.{m[3]}"
m = re.match(r"([\w/.-]+)\.(\w+)", s)
return (m[1], m[2]) if m else (None, s)
そのまま描くと団子になる
1,291 個の点と2,516本の線を、ありがちな力学モデル(つながった点どうしが引き合い、全体が反発し合う配置)で描くと、こうなります。

線が多すぎて、どこに何があるのか読めません。パッケージの色も全体に散らばっています。
冒頭の図にするために変えたのは3点です。
1. パッケージごとに見えない「錨」を置く。 パッケージごとにダミーの点を1つ足し、そのパッケージの関数を全部ダミーにつないでから配置を計算します。同じパッケージの関数が1か所に集まります。島ができます。ダミーの点は描く前に外します。
2. パッケージ内とパッケージ間で、線の引く強さを変える。 同じパッケージ内の呼び出しは重み1.0、パッケージをまたぐ呼び出しは0.15にしました。島の中は詰まり、島どうしは細い線でゆるくつながります。
3. 点の大きさを「呼ばれている数」にし、背景を暗くする。 呼ばれる回数が多い関数ほど大きく描くので、よく使われる関数が目に入ります。線は透明度を0.1〜0.2まで下げます。暗い背景に半透明の線を重ねると、線が集中するところだけが明るく浮きます。
H = nx.Graph()
for a, b in G.edges:
same = pkg[a] == pkg[b]
H.add_edge(a, b, weight=1.0 if same else 0.15)
for n in G:
H.add_edge(n, "@" + pkg[n], weight=0.6) # パッケージの錨
pos = nx.forceatlas2_layout(H, max_iter=1500, scaling_ratio=4.0,
gravity=0.5, linlog=True, weight="weight", seed=7)
pos = {n: p for n, p in pos.items() if n in G} # 錨は描かない
配置には networkx に入っている ForceAtlas2(nx.forceatlas2_layout)を使いました。linlog=True にしました。島どうしの間隔が広がるので、かたまりが分かれて見えます。seed を固定しておけば、何度描いても同じ配置になります。このあとの比較図で、同じ配置に別の線を重ねられるのはこのためです。
同じ配置に graphify の線だけを描く
同じ1,291個の関数を同じ位置に置いたまま、graphify が取れた呼び出しだけを描きました。左が Go 公式の callgraph、中央が graphify 0.9.6、右が 0.9.106 です。

中央の 0.9.6 は島が並ぶだけで、島どうしをつなぐ線がありません。灰色の点は、どの呼び出しにも乗らなかった関数です。右の 0.9.106 では島どうしの線が戻りますが、左と比べるとまだ疎らです。
数字にするとこうなります。
名前で確定する呼び出し(-algo=static)のうち、graphify の関数と対応付けられた範囲で数えました。
| 再現率 | パッケージをまたぐ呼び出し | |
|---|---|---|
| graphify 0.9.6(EXTRACTED) | 53% | 0 / 541 |
| graphify 0.9.106(EXTRACTED) | 52% | 316 / 543 |
| graphify 0.9.106(EXTRACTED + INFERRED) | 77% | 333 / 543 |
0.9.6 で取れていたのは、同じファイルの中の呼び出しがほぼ全部です。1,298本のうち1,296本が同じファイル内で、パッケージをまたぐものは1本だけでした。0.9.106 では、llm.CountTokens(...) のようにパッケージ名を付けて呼ぶ形が解決されるようになっています。
どの版で直ったのかは調べていません。
graphify が取れた呼び出しは、0.9.106 では全部が Go 公式の結果にも含まれていました(1,485本中1,485本)。間違った線はなく、取りこぼしがあるだけです。
1つの関数で追う: llm.CountTokens
数字だけでは実感しにくいので、1つの関数を追います。internal/llm の CountTokens は、テキストのトークン数を数える関数です。ソースを grep すると、テストを除いて11か所から呼ばれていました(呼び出し元の関数は10個)。
internal/agent/estimate.go:59 diffTokens := int64(llm.CountTokens(d.Diff))
internal/agent/grouping.go:353 total += int64(llm.CountTokens(d.Diff))
internal/scan/agent.go:460 decision.tokens = llm.CountTokens(it.Content)
internal/llmloop/compression.go:68 return llm.CountTokens(m.ExtractText()) + ...
internal/session/history.go:496 promptTokens += llm.CountTokens(m.ExtractText()) + ...
(ほか6か所)
呼び出し元は agent・scan・llmloop・session の4パッケージに散らばっています。この関数を変えたときに影響を受けうる上流の関数(呼び出し元をさかのぼってたどれる関数)を数えると、次のとおりです。
| 上流の関数の数 | |
|---|---|
| Go 公式 callgraph(vta) | 61 |
| graphify 0.9.6 | 0 |
| graphify 0.9.106(EXTRACTED) | 18 |
| graphify 0.9.106(EXTRACTED + INFERRED) | 24 |
0.9.6 のグラフで影響範囲を出すと、「この関数を変えても、ほかに影響はない」という答えになります。0.9.106 は直接の呼び出し元の関数10個を全部拾えていますが、そこから先が途切れ、上流は24です。internal/llm の関数148個(Error() メソッドを除く)で同じことをすると、上流の数の中央値は Go 公式が43、graphify 0.9.106 が4でした。ただし vta はインターフェース経由で呼ばれうる先を全部張るので、43は多めに出た上限として読んでください。たとえば OpenAI・Anthropic・Responses API の3つのクライアントは、同じメソッド名を持っているので、上流も同じ数になっています。
影響範囲をグラフで出す前に
AI のレビューやマージの自動化が進むと、「この変更はどこまで影響するか」をグラフで機械的に出したくなります。今回分かったのは、そのグラフ自体が何を取れていないかを先に測っておかないと、「影響なし」がグラフの見落としなのか本当に影響がないのか、区別できないということです。
もう1つ、版を固定したツールのグラフは古くなります。私の計測基盤では、graphify を 0.9.6 に固定していました。0.9.6 が出た7月4日から0.9.106 が出た11月10日までに100回リリースがあり、Go のパッケージをまたぐ呼び出しの抽出が直っていました。固定したままだと、そういう改善にも気づけません。
比較の手順はそれほど重くありません。
- 言語公式の解析ツールで呼び出しグラフを作る(Go なら
callgraph -algo=static) - 自分のツールのグラフと関数名で突き合わせ、再現率と、パッケージをまたぐ呼び出しの再現率を出す
- 同じ配置で2枚描いて並べる
3の図は省かないでください。
数字だけでは「53%」が島だけの53%なのかどうか見分けられませんが、図にすると一目で分かります。
限界
- 検証したのは Go の1リポジトリだけです。Python や TypeScript で graphify がどこまで取れるかは測っていません
- graphify の関数を Go 側に対応付けるとき、同じディレクトリに同じ名前のメソッドが複数あるものは外しました。対応付けで落ちた分を含めると、再現率は 0.9.106(INFERRED 込み)で77%から67%に下がります
vtaも、リフレクションや plugin 経由の呼び出しは取れません。Go 公式の結果も、実行時の呼び出しをすべて含む正解とは言えません。静的解析で取れる範囲の上限です- 0.9.106 の取りこぼしのうち、どの書き方の呼び出しが抜けているかまでは分類していません
再現用のスクリプト
グラフの作成・比較・描画のスクリプト一式と、callgraph・graphify の生の出力は手元に保存しています。上のコードは、そこから要点を抜き出したものです。描画の全体は、上の配置計算に nx.draw_networkx_edges と nx.draw_networkx_nodes を足すだけです。線はパッケージ内とパッケージ間に分けて色と透明度を変え、点は node_size = 6 + 呼ばれている数 × 9 にしています。
続き
この呼び出しグラフを実際の PR に当て、後から直された取りこぼしを事前に出せたかを確かめた続きは、PRの影響範囲をコードグラフで出してみたにあります。
この記事の元になった本
コードをグラフにする考え方(tree-sitter での構造抽出、影響範囲をホップ数で測る方法、ノードとエッジの設計)は、拙著『ナレッジグラフ活用大全』の「第8章: コードをグラフで理解する」にまとめています。今回のように、作ったグラフを公式ツールと比べる手順は、本には入っていない追試です。
関連書籍 ナレッジグラフ活用大全 ナレッジグラフ 活用大全 | GraphRAG・Neo4j・RDF・Property Graph・Emotion AI の実践書 書籍ページを見る → この記事は役に立ちましたか?