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 (9)

  1. Kenvia dev.to

    This is a strong argument for using static analysis as a source of grounded edges rather than just dumping more code into a model context. The boundary-node idea is the key part for me: API, DB, and event edges need provenance and freshness so an agent can ask “what do I know, how was it derived, and where might this graph be incomplete?” That makes the graph useful as an inspection surface, not just retrieval context.

    1. Ryosuke Tsujivia dev.to

      That's exactly the framing I want to push toward. Today the closest we have is a daily boundary-analysis cron that compares connection rates day-over-day — it catches a class of staleness ("a parser fell behind a new pattern, a whole API class went invisible") but it doesn't yet give the agent first-class provenance per edge. The piece I'd most like to layer on top of that is dynamic analysis — production execution counts per edge — so the agent can distinguish "edge exists in static analysis" from "edge actually fires N times per day in production." That turns the graph from "what could be called" into "what is actually called," with dead-code edges visible as a separate signal. Not covered in Part 2 — still on my circling-around list.

  2. UnitBuildsvia dev.to

    I built something similar at some point, started off with just a roslyn script for dependency graphing, into a mermaid graph. Switched to Neo4J, then wrote a custom graph db. Purely so the AI can understand relations and cause and effect. Didnt hurt that with a constraint metric let it generate integration and unit tests... So stripped the graphing, but kept the roslyn and added more boilerplate removal, rule enforcement, simplified initialization, performance optimization, translations and called it V.A.L.I.D.

    1. Ryosuke Tsujivia dev.to

      Interesting trajectory — that's a lot of graph DB swapping. We went the other direction and kept everything in BigQuery from the start: the data volume doesn't really justify a dedicated graph DB (the whole monorepo fits as a few tables with edges modeled as joins), and once BigQuery Graph (still preview) hits GA, the traversal speed gap should mostly close. The constraint-metric-into-test-generation angle you mentioned is interesting — that's an axis we haven't really pushed yet.

    2. UnitBuildsvia dev.to

      Graph DB's are interesting, because while they all do the job, some do some jobs better. Especially when it comes to additional detail. Eg. mapping every method and variable in a codebase, then cross-referencing their interactions, method of interaction, functional modifications and state-tracking across hidden layers, you deal with quite a long path, instead of just 'this is front end, this is backend, for this item', which is where it becomes a case of how much info is enough for you and how many layers do you want (i.e. keep it low context for initial load, followed by deeper context on query), for when you want to use a LLM to suggest changes. Then wiring in a MCP and skills file, so the LLM doesnt need to trace it step by step, it's just A-Z pathfinding across the graph db, following all the vectors and map constraints for each step, to see what might break it. (a bit of what I built into the software foundry).

    3. Ryosuke Tsujivia dev.to

      The MCP-as-conversation-partner direction overlaps with what I wrote here: [dev.to/ryantsuji/graph-rag-isnt-a-...](https://dev.to/ryantsuji/graph-rag-isnt-a-one-shot-anymore-the-case-for-agentic-graph-rag-mcps-1dj5) — we let Claude Code orchestrate 10-20 tool calls per session against the graph, with each response embedding "next move candidates" so the agent pathfinds without a hand-coded trace. The layered loading you mention (low context initial, deeper on query) is something we don't do explicitly yet — per-call granularity stays flat. That's an interesting axis worth thinking about.

    4. UnitBuildsvia dev.to

      I made a custom MCP system, where it queries the tool, with a LoD, so it can reference how much context it needs for the call, which tied with an efficiency scorer (success:token draw), so it can see what LoD is the sweet spot, it dynamically adjusted it's requirements as it needed to for the task I gave it, so it doesnt need repeated tool calls to achieve the goal. When combined with a scripting tool, to build a flow, it works really well.

  3. Nazar Boykovia dev.to

    The recall math is the part that justifies writing a fresh parser for every framework. People wave off "90% is fine," but once you're walking three or four hops, 0.9 per hop quietly turns into a coin flip, and for tracing what a change breaks, the missing link is the one that causes the incident. I also appreciate that you named the entry point problem instead of hiding it: the graph is great once you know where to stand, but finding where to stand from a vague bug report is still a grep. I'm curious whether Part 2's SPG layer is mostly aimed at that, since it feels like the thing standing between this and asking the graph in plain English.

    1. Ryosuke Tsujivia dev.to

      Yes — SPG (Part 2) is exactly aimed at that entry-point problem. The shift is: instead of extracting structure from code (where you have no semantic anchor to land on), you write the intent into the code as JSDoc tags — what the function is for, business context, stack — and vectorize that into the node. "The subscription fee calculation for members" then lands you on the right function without knowing its name. The catch is the annotations have to actually exist at scale, which only works if AI is writing them and AI is reviewing them. But once that pipeline runs, the entry point becomes a semantic query rather than grep + inference. (And yeah — the recall math is the actual justification for the per-framework parser work. Walking a graph isn't worth doing if each hop is a coin flip.)