46リポジトリに跨るコードベースを、静的解析で一つのナレッジグラフにした話(前編)

12 min read

目次

  1. 何のために作ったか
  2. 規模感: 46リポジトリ
  3. なぜ境界ノードが重要なのか
  4. 構築: tree-sitterベース、必要なところでTypeScript CompilerとGeminiを併用
  5. 境界ノードの抽出と接合の苦労: 1月〜3月の試行錯誤
  6. 1月: スタート、そしてtree-sitterだけでは足りないと気付く
  7. 2月: 多様なフレームワークへの対応とノイズとの戦い
  8. 3月: 概念整理と細かな精度向上
  9. このタイムラインから見える話
  10. なぜそこまで精度にこだわるか
  11. 境界分析の運用 ── 今も毎日動いている
  12. それでも残る課題
  13. 1. セマンティック検索ができない(入口の問題)
  14. 2. ノード爆発
  15. 3. 関数の中身は結局ファイルを見ないと分からない
  16. 4. 新しい境界パターンが出るたびにparserを書き足す運用負荷
  17. 補足: 別の場所では別の判断 ── cortexの話
  18. 本番システム側にはアノテーションベースは現実的ではない
  19. つづく

みなさまこんにちは!エアークローゼットでCTOをしているです。

今回は、複数サービス合計46リポジトリに跨る本番システムのコードベースを、静的解析で1つのナレッジグラフに統合した話です。

社内ではcode-graphと呼んでいて、今年の1月から3月にかけて構築しました。

書き残しておきたい論点が3つあります。

本記事は前編で、code-graph自体の構築と苦労、そして残った課題までを書きます。後編では、code-graphをbaseにしつつ別レイヤーで補強したservice-product-graph(SPG)の話を予定しています。

何のために作ったか

長年積み上がった本番システムのコードベースは、ふつう、次のような状態になります。

このコードベース全体に対してAIに「影響範囲を見てほしい」「ここを変えたら何が壊れるか調べてほしい」と頼みたい、というのが出発点でした。

ここで素直に考えると、「AIに全リポジトリのコードを丸ごと渡して解析してもらえばいいんじゃないか」 となります。

ただ、それは2つの理由で無理です。

そこで、まず考えついたのが「外側から静的解析でナレッジグラフを作る」 というアプローチでした。これがcode-graphの出発点です。

規模感: 46リポジトリ

対象は2つのgraphに分かれています。

合計で46リポジトリ

ポイントは、これが「1サービスで」 ではなく複数サービスの集合でこの規模になっている、という点です。サービス境界そのものを跨ぐ依存関係をクロスリポジトリのedgeとして見えるようにしているのが、このあと出てくる境界ノードの話に繋がります。

なぜ境界ノードが重要なのか

ここがこの記事の中心です。

AIにコードを理解させるとき、目の前にあるコードや、その横にあるコードを「読ませる」のは、別に難しくありません。grepして該当ファイルを開いて読ませる、これで十分機能します。

ただ小規模なコードベースならそれだけでも十分ですが、大規模なコードベースで同じことをしても、前述のコンテキストウィンドウやハルシネーションの問題が発生します。きっとこれを読んでいる多くの方も共感するのではないでしょうか。

これを改善する一つの方法として、コードベースを静的解析し、ナレッジグラフに変換してMCP経由でAIに供給するという手法をとっています。

そのまず第一歩目として行ったのが、tree-sitter(ソースコードを構文木にパースするOSSライブラリ。多くの言語に対応していて、VS Code等のエディタの構文ハイライトでも使われている)を使った静的解析でした。このツール自体は非常に有用なので、似たようなことをしたい方には非常におすすめなのですが、tree-sitterだけでは解決できないこともあります。

それはAPIやデータベース等の境界をまたいだ関係性の追跡です。tree-sitterはプログラミング言語の変数や関数等の処理の関係性を解析し抽出することはできますが、そういった境界を抽出することはできません。

しかし実際に人でもAIでも躓きがちなのは、こうした境界をまたいだコードの繋がりです:

要するに、「境界の先にあるコード」をAIにハルシネーションを起こさせずに把握させること。これが目的です。

境界ノードがリポジトリの壁を越えてコードを繋ぐ

境界ノードを取れていれば、AIは「このAPIは他にも〇〇 repoで叩かれています」と事実として答えられます。AIに推論させるのではなく、事前に解決済みの事実として渡せるわけです(抽出時にはTypeScript CompilerやGeminiで推論が入りますが、その結果はgraphに確定値として保存され、後述する境界分析の日次cronでドリフトを翌朝検知できる状態にしています。AIが消費する時点では、検証された事実だけが渡る形になります)。

AIは「分からないこと」を「分からない」と返すより、見えている範囲で何かしら返してしまう傾向があります。ここで起きるのが、サイレントなハルシネーション。AI自身も、それを受け取る人間も気付かない誤答です。境界ノードは、これを物理的に塞ぐ事実の拠り所になります。

構築: tree-sitterベース、必要なところでTypeScript CompilerとGeminiを併用

通常のコード(関数呼び出し / クラス継承 / import関係)は、tree-sitterで比較的簡単に取れます。ASTを辿って関数 / メソッド / クラス / フィールドをノードにして、参照関係をエッジで結ぶ。これは粛々とやるだけです。

ただ、tree-sitterは構文木を作るのは得意ですが、型情報やスコープ解析は弱い。フィールドアクセス(user.preferences.themeのようなチェーン)を正確に追うには、変数userがどの型でどう定義されているかを解決する必要があります。これはtree-sitter単体だと手が届かない。

そこで、フィールドアクセスの解決にはTypeScript Compiler APIGeminiを併用しています。tree-sitterで構造を抽出 → TypeScript Compilerで変数や型を解決 → それでも解決しきれない動的なケースはGeminiで推定、という役割分担で精度を上げています。

フィールドアクセス解決の3段階の役割分担

エッジは21種類定義してあります。

本当の戦いは、境界エッジ(CALLS_API / HANDLES_API / EMITS_TO / SUBSCRIBES_TO / WRITES_TO / READS_FROM)を取りに行くところからです。

境界ノードの抽出と接合の苦労: 1月〜3月の試行錯誤

通常のコードと違って、境界(APIエンドポイント / DBテーブル / Eventトピック)は、フレームワーク・言語・技術領域・ライブラリ・リポジトリ・書いた人によって、書き方がバラバラです。

同じ「APIエンドポイントを定義する」という意味の処理でも、Expressで書くか、NestJSの@Get()デコレーターで書くか、Fastifyのrouteで書くかで、まったく違うAST形になる。さらに、同じリポジトリの中でも複数パターンが混在することがある。

そして、苦労するのは抽出だけではありません。抽出した境界をgraph上で接合するのもまた面倒です。同じAPIパスやDBテーブル名でも、

といった揺れが混在します。これらを統一した形に正規化して、callerとhandler、emitterとsubscriber、書き込み側と読み込み側を正しく繋ぎ合わせるのが本当に大変でした。

これを46リポジトリ×多様なフレームワークすべてに対して取りに行く必要がありました。

実際に当時のgit履歴を覗くと、毎週のように新しいparserやdetectorが足され、ノイズフィルタが追加され、概念整理が入っています。ここに1月から3月にかけての主要なcommitを時系列で並べてみます (commit prefixは当初の graph-rag (= ナレッジグラフ + RAG として LLM に供給する基盤、という発想のスタック名) で始まって、2月15日に code-graph に統一されています)。

1月: スタート、そしてtree-sitterだけでは足りないと気付く

2月: 多様なフレームワークへの対応とノイズとの戦い

3月: 概念整理と細かな精度向上

このタイムラインから見える話

毎週、新しいフレームワークやパターンへの対応が入っています。「境界ノードを取りに行く」という作業は、要するに多種多様な書き方それぞれにparserを足していく作業です。

具体的に登場したフレームワーク / 仕組みだけ並べても、こうなります。

単に「TypeScript / JavaScript / Go / Dartなどの静的解析」と言って収まる話ではありません。air-closet系のコードベースは長く動いてきた本番システムの集合体で、各時代のフレームワークが共存しています。それぞれの時代の「ここにAPIエンドポイントがある」「ここでDBを叩いている」「ここでEventを購読している」という意味を、ASTから拾い上げる必要がありました。

なぜそこまで精度にこだわるか

90%の精度では、まったく使い物になりません。

たとえば「このAPIを叩いている処理を全部出して」という用途で90%の精度しか出なければ、10%の処理はAIから見えない。影響範囲を調査するためにcode-graphを使う場合、この見えなかった10%が事故を起こします

しかも、graphを辿る用途では、ホップごとに精度が累乗で下がります。1ホップで0.9なら、2ホップで0.81、3ホップで0.729、5ホップで約0.59、10ホップで約0.35 ── 数ホップ辿っただけで結果は半分以下です。一方、99%にまで持っていけば、2ホップで0.98、5ホップで0.95、10ホップでも約0.90を保ちます。実用に耐えるかどうかは、まさにこの一桁の差で決まります

新しい境界パターンが見つかるたびに独自パーサーを書き足し、境界の接続率を99%以上に保つことを目標にしてきました。「全境界」という分母を持てない以上、抽出網羅率を直接測ることは難しいので、実際に毎日測れる指標としては「caller側とhandler側がgraph上でちゃんと繋がっている割合」 = 接続率を使っています。次のセクションでその監視の仕組みを書きます。

境界分析の運用 ── 今も毎日動いている

ここまで作ったcode-graphは、今も毎日動いています。

具体的には、毎日JST 7

境界分析のcronが動いています。やっていることは:

集計結果を毎日比較して、接続率が前回比5%以上劣化していたら、Grafana経由でアラートを上げます。

これは「境界ノードを取れている前提」で初めて成立する運用です。「取れている境界の質」そのものを日次で監視している、というメタな仕組みになっています。接続率で拾える種類のドリフト ──「parserが新しいパターンに対応できておらず境界の一部が見えなくなった」「リポジトリの構成が変わってpath aliasが解決できなくなった」── は、翌朝には検知できる状態になっています。一方で接続率では拾えないドリフト(caller 側 parser のリグレッションで caller がまるごと消えた場合、handler は残っている他の caller と「繋がっている」 ように見えてしまい、消えた caller は静かに落ちる)は別軸で、リポジトリ / パターン別の絶対ノード数を日次比較してカバーしています。

それでも残る課題

ここまでやっても、いくつか根本的に解けない課題が残ります。

1. セマンティック検索ができない(入口の問題)

検索MCPツールは、LIKEによる文字列部分一致のみです。

開発中で「いま自分が見ている関数の繋がりを辿りたい」ような場合は、その関数名やファイル名で直接引けるので、これでも問題ありません。

問題になるのは、本番のバグやお客さまからの問い合わせを調査するときです。関連するコードのファイル名や関数名なんて、最初は分からない。「会員のサブスク料金計算が間違っているらしい」という入力から関連コードを辿りたいときに、自然言語クエリでgraphを引けないと、そもそも入口が見つけられない

「コードベース全体をgrepで検索するのではなく、graph RAGで関連性を辿れる」という目論見だったのですが、入口でgrepして推論せざるを得ない構造になっています。

2. ノード爆発

tree-sitterでASTを素直にグラフ化すると、builtin functionや無名関数、内部utilityまで全部ノードになります。実用上は不要な「このmap呼び出し」「この内部helper」まで全部ノード化されてしまう。

ある起点ノードから関連ノードを辿る走査をかけると、数ホップでhelperや型やprimitiveを巻き込んでノード数が爆発します。「関連性で絞る」ための軸が、グラフ構造の中にありません。

走査を境界ノードで止めるような明示制御で運用上は逃げていますが、根本対処ではありません。

3. 関数の中身は結局ファイルを見ないと分からない

graphで「ここに何かある」「ここから別のrepoの処理を呼んでいる」までは分かります。でも「この関数が具体的に何をしているか」は、結局ファイルを開いて読まないと分からない。

graph単体では時間がかかります。後に作ったコードベース調査ツールでは、graphから候補ファイルを絞った上でGit Server MCP経由で実際のファイルを読ませる、という形で逃げていますが、graph単体での解像度の限界はそのまま残ります。

4. 新しい境界パターンが出るたびにparserを書き足す運用負荷

フレームワーク / ライブラリが新しく入るたびに、「そのフレームワークでは境界をこう書く」を学んでparserを書き足す必要があります。

すでにparserディレクトリには10個以上の独自detector / extractorが並んでいます。維持と拡張のコストが下がる兆候はなくて、新しい技術スタックがコードベースに入ってくるたびに同じ作業を繰り返すことになります。

補足: 別の場所では別の判断 ── cortexの話

ここまでcode-graphの話を書いてきましたが、補足として、自分は別途、cortexという社内AIプラットフォームを1から作っているプロジェクトを持っています(今は100+ appsの1モノレポ)。

そちらでも最初はcode-graphと同じアプローチを試したのですが、早い段階で見送って、アノテーションベースのナレッジグラフに倒しています。

この「意図をコードに書き込んでgraph化する」という判断と、その判断に至るまでの試行錯誤については、別連載で詳しく書いています。興味があれば: AIハーネス連載Part 2(AIのAIによるAIのためのナレッジグラフ)

本番システム側にはアノテーションベースは現実的ではない

そして、今回code-graphで扱っている本番システム側に同じアプローチが取れるかというと、これは無理です。

だから、code-graph(静的解析ベース)をbaseにしつつ、別レイヤーで補強する方向に進化させる、という選択を取りました。

この課題をどう解決しようとしているかは、後編で別途説明します。

つづく

前編はここまでです。後編では、上記の課題をどう乗り越えようとしているかについて書く予定です。

「捨てた」のではなく「進化させた」、というのが本当のストーリーです。

長文をお読みいただきありがとうございました。

comments (0)

まだコメントはありません。