大規模コードベースを3秒で可視化する:repomap開発記
目次
課題:「このページでどのAPIを呼び出していますか?」
解決策:repomap
パフォーマンス
主要機能
1. ページ → データ接続の追跡
2. フレームワーク自動検出
3. インタラクティブグラフビュー
開発で学んだこと
1. ASTパース:「簡単そうに見えるもの」の罠
2. パスマッピング:エッジケースの沼
3. ハードコーディングの誘惑に打ち勝つ
4. GraphQL追跡:importグラフを辿る
5. GraphQL Code Generator:命名規則の多様性
6. パフォーマンス:SWCを選んだ理由
結論
今後の予定
課題:「このページでどのAPIを呼び出していますか?」
フロントエンド開発者なら誰でも一度はこんな質問を受けたことがあるでしょう。
- 「この画面で呼び出しているGraphQLクエリは何ですか?」
- 「このAPIを修正したら、どのページに影響しますか?」
コードベースが大きくなるほどこれらの質問に答えるのが難しくなります。grepで検索してもimport chainを辿っていくと迷路に迷い込んだ気分になりますよね。
解決策:repomap
repomapはコードベースを分析してページ・コンポーネント・API呼び出しの関係を可視化するツールです。
bash
npx @wtdlee/repomap serveこの一行でローカルサーバーが起動し、ブラウザでプロジェクト全体の構造を探索できます。
パフォーマンス
最も注力したのは速度です。
- Next.jsアプリ
- 30+ページ、900+コンポーネント
- 600+ GraphQL
- → 約3.2秒
- Railsモノリス
- 5,000+ルート、400コントローラー
- → 約3.5秒
秘訣はSWCです。Rustで書かれた超高速パーサーのおかげでts-morphと比較して10倍以上速い分析が可能になりました。
主要機能
1. ページ → データ接続の追跡
単に「このページがある」だけでなく、どのような経路でGraphQLクエリが呼び出されるかをエビデンスチェーンで表示します。
page.tsx:47
→ @/server/github/projects
→ ./mutations
→ UpdateProjectV2ItemTextFieldValueServer Componentで直接呼び出しても、ユーティリティ関数を経由しても追跡します。
2. フレームワーク自動検出
Next.js Pages Router、App Router、React SPA(react-router-dom)、Railsまで設定なしで自動検出します。tsconfig.jsonがないJavaScriptプロジェクトもサポートしています。
3. インタラクティブグラフビュー
ページ間の関係をforce-directedグラフで可視化します。複雑なナビゲーション構造を一目で把握できます。
開発で学んだこと
1. ASTパース:「簡単そうに見えるもの」の罠
問題:Next.js App Routerのページが検出されませんでした。
最初は「default exportを見つける」のは簡単だと思っていました。しかし、実際のコードベースは想像以上に多様でした。
// ケース1:関数宣言(Next.js App Routerで最も一般的)
export default function HomePage() { }
// ケース2:async関数宣言(Server Components)
export default async function HomePage() { }
// ケース3:無名関数式
export default function() { }
// ケース4:アロー関数
export default () => <div />
// ケース5:変数参照
const Page = () => <div />
export default Page
// ケース6:クラスコンポーネント(レガシー)
export default class HomePage extends React.Component { }ASTではこれらは完全に異なる構造になります。
// ケース1, 2:FunctionDeclaration(関数宣言)
export default function HomePage() { }
export default async function HomePage() { }
→ { type: 'ExportDefaultDeclaration', decl: { type: 'FunctionDeclaration', identifier: { value: 'HomePage' } } }
// ケース3:FunctionExpression(無名関数式)
export default function() { }
→ { type: 'ExportDefaultDeclaration', decl: { type: 'FunctionExpression', identifier: null } }
// ケース4:ArrowFunctionExpression(アロー関数)
export default () => <div />
→ { type: 'ExportDefaultDeclaration', decl: { type: 'ArrowFunctionExpression' } }
// ※ アロー関数はidentifierがない - JSXからコンポーネント名を推論する必要がある
// ケース5:Identifier(変数参照)
const Page = () => <div />
export default Page
→ { type: 'ExportDefaultDeclaration', decl: { type: 'Identifier', value: 'Page' } }
// ケース6-a:ClassDeclaration(名前付きクラス)
export default class HomePage extends React.Component { }
→ { type: 'ExportDefaultDeclaration', decl: { type: 'ClassDeclaration', identifier: { value: 'HomePage' } } }
// ケース6-b:ClassExpression(無名クラス)
export default class extends React.Component { }
→ { type: 'ExportDefaultDeclaration', decl: { type: 'ClassExpression', identifier: null } }特にケース4(アロー関数)が厄介でした。名前がないため、JSX内で使用されているコンポーネント名を逆に推論する必要がありました。
// アロー関数は名前がないので...
export default () => (
<Layout>
<HomeContent /> // ← ここから「HomeContent」をメインコンポーネントとして推論
</Layout>
)教訓:「export default」という一つの構文も、ASTでは6〜7つのケースに分かれます。実際のプロジェクトコードを多く見ることが重要です。
2. パスマッピング:エッジケースの沼
問題:App Routerのルートページ(app/page.tsx)が/pageにマッピングされていました。
原因は単純な正規表現でした。
// Before:末尾に/pageがあれば削除
path = path.replace(/\/page$/, '');
// app/page.tsx → "page" → 正規表現マッチせず → "/page" 😱修正:
// After:/pageも、page自体も処理
path = path.replace(/\/page$/, '').replace(/^page$/, '');
// app/page.tsx → "page" → "/" ✅教訓:文字列処理で「境界条件」は常にバグの温床です。/page、page、/page/、page.tsxなど、すべてのバリエーションをテストする必要があります。
3. ハードコーディングの誘惑に打ち勝つ
問題:特定のプロジェクトでのみコンポーネントが検出されていました。
初期の実装はこうでした。
// "features"、"components"フォルダのimportのみコンポーネントとして認識
const componentPathPatterns = ['features', 'components', 'containers', 'views'];
if (componentPathPatterns.some(p => importPath.includes(p))) {
// コンポーネントとして処理
}当然、他のプロジェクト構造では動作しませんでした。
解決策:「プロジェクト内部のimportか?」を判断する汎用ロジックに置き換え。
function isProjectImport(source: string): boolean {
// 相対パス → プロジェクトimport
if (source.startsWith('./') || source.startsWith('../')) return true;
// エイリアスパターン(@/、~/、#/)→ プロジェクトimport
if (/^[@~#]\//.test(source)) return true;
// scoped package(@radix-ui/react-dialog)→ 外部
if (source.startsWith('@') && !source.startsWith('@/')) return false;
// 小文字で始まるbare import(react、lodash)→ 外部
if (/^[a-z]/.test(source)) return false;
return true;
}教訓:特定のプロジェクトで動作するコードを書くのは簡単です。しかし「なぜこれがコンポーネントなのか?」を原理的に定義すれば、汎用的な解決策が生まれます。
4. GraphQL追跡:importグラフを辿る
問題:ページからGraphQLクエリが接続されていませんでした。
ページのコードを見ると
// app/settings/page.tsx
import { listProjects } from '@/server/github/projects';
export default async function SettingsPage() {
const projects = await listProjects(); // GraphQLを呼び出す関数
return <ProjectList data={projects} />;
}GraphQL呼び出しは@/server/github/projects/queries.tsにあります。
// server/github/projects/queries.ts
import { ListIterations } from '../__generated__/github-documents';
export async function listProjects() {
return octokit.graphql(print(ListIterations), { ... });
}ページ → ユーティリティ関数 → GraphQL、2段階以上離れていると接続が切れていました。
解決策:importグラフを再帰的に巡回
function findGraphQLInImportChain(
pageFile: string,
visited: Set<string> = new Set()
): GraphQLOperation[] {
if (visited.has(pageFile)) return []; // 循環参照を防止
visited.add(pageFile);
const imports = extractImports(pageFile);
const results: GraphQLOperation[] = [];
for (const importedFile of imports) {
// 直接のGraphQL使用を確認
results.push(...findDirectGraphQLUsage(importedFile));
// 再帰的にimport chainを探索
results.push(...findGraphQLInImportChain(importedFile, visited));
}
return results;
}そして接続経路を「エビデンスチェーン」として記録
page.tsx:47 → @/server/github/projects → ./queries → ListIterations教訓:静的解析の限界は「間接参照」です。importグラフを構築して巡回すれば、ほとんどのケースをカバーできます。
5. GraphQL Code Generator:命名規則の多様性
問題:一部のプロジェクトでGraphQL operationがusedIn: []になっていました。
codegen設定によってexport名が異なります。
// パターン1:Document接尾辞(apollo-client preset)
export const ListIterationsDocument = gql`...`;
export function useListIterationsQuery() { ... }
// パターン2:operation名そのまま(一部のpreset)
export const ListIterations = gql`...`;
// パターン3:hookのみexport
export function useListIterations() { ... }既存のコードはDocument接尾辞のみを探していました。
// Before
const operation = operationByDocument.get(name); // "ListIterationsDocument"のみマッチ解決策:3つのインデックスをすべて確認
// After
const operation =
operationByDocument.get(name) || // ListIterationsDocument
operationByVariableName.get(name) || // useListIterationsQuery
operationByName.get(name); // ListIterations教訓:エコシステムツール(codegen、babel pluginなど)の出力形式は予想以上に多様です。「このツールはこう動作するはずだ」という仮定は危険です。
6. パフォーマンス:SWCを選んだ理由
当初はts-morphを使用していました。TypeScriptの公式コンパイラAPIをラップしたライブラリで型情報まで取得できますが、遅いことに課題間を感じました。
ts-morph:970ファイル分析 → 約35秒
SWC:970ファイル分析 → 約3秒静的解析に型情報が本当に必要なのか?ほとんどのケースでASTだけで十分であったのと、10倍の際でSWCを採用することにしました。
- ページファイル検索:ファイルパス + export構造
- GraphQL追跡:import文 + 関数呼び出しパターン
- コンポーネント検出:JSX構文 + PascalCase命名
教訓:「より多くの情報」が常に良いわけではありません。必要な分だけパースすれば、パフォーマンスが劇的に改善されます。
結論
静的解析ツールを作りながら気づいたこと。
- 実際のコードは予想以上に多様:ドキュメントの「推奨パターン」と実際のコードベースは異なります
- ハードコーディングは技術的負債:原理を理解し、汎用的に実装しましょう
- エッジケースこそが本質:90%をカバーするのは簡単で、残りの10%が難しい
- パフォーマンスは設計段階で:後から最適化するより、最初から速いツールを選びましょう
今後の予定
- VSCode拡張(現在ベータ版)
- https://marketplace.visualstudio.com/items?itemName=wtdlee.repomap-vscode
- Cursor Extensionもダウンロードできます。
- PRプレビュー自動生成
- 変更影響範囲分析(diff基盤)
https://www.npmjs.com/package/@wtdlee/repomap
npm install -g @wtdlee/repomap大規模コードベースを管理されている方はぜひ一度使ってみてください。フィードバックはいつでも大歓迎です!