6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScript の依存関係をシンボル単位で可視化する VSCode 拡張機能を作ってみた

Posted at

はじめに

依存関係に方向性のあるアーキテクチャ(クリーンアーキテクチャなど)では、各層が独立していて他の層への依存が少ないのは良いのですが、逆に「この変更の影響範囲はどこまで及ぶんだろう?」「シンボル間のつながりがどうなっているんだろう?」というのが掴みづらいと感じていました。

この課題を解決するために、思い切って VSCode 拡張機能を作ってみることにしました。対象言語は普段私がよく使っている TypeScript + React です。

VSCode 拡張機能を作るのは今回が初めてで、最初は「難しそう...」と思っていたのですが、意外と形になったので、その過程を共有したいと思います!

作ったもの

TypeScript プロジェクトの依存関係を可視化して、アーキテクチャの「一方通行ルール」が守られているかをチェックする拡張機能です。

このツールのこだわりポイント 🎯

ファイル単位ではなく、シンボル単位(関数・クラス・変数)で依存関係を見る

既存のツールはファイル間の依存関係を表示するものが多いのですが、私が実際に知りたかったのは「このUserクラスがどこで使われているか」だけでなく、その先でさらにどう使われているかという依存関係の連鎖でした。

例えば

// domain/entities/User.ts
export class User { }

// domain/repositories/UserRepository.ts
import { User } from '../entities/User';
export class UserRepository {
  findById(id: string): Promise<User> { ... }
}

// usecases/GetUserUseCase.ts
import { UserRepository } from '../domain/repositories/UserRepository';
export class GetUserUseCase {
  execute(id: string) {
    return this.userRepository.findById(id);
  }
}

// presentation/UserController.ts
import { GetUserUseCase } from '../usecases/GetUserUseCase';
export class UserController {
  async getUser(req, res) {
    const user = await this.useCase.execute(req.params.id);
  }
}

一般的なツールでは「User → UserRepository」という 1 つ 1 つの繋がりは分かります。

でも私が知りたかったのは、User クラスの依存の流れ全体でした。

UserUserRepository.findById()GetUserUseCase.execute()UserController.getUser()

この連鎖を辿ることで、「User クラスを変更したら、どの範囲に影響が及ぶのか」「依存の方向は正しいか(逆流していないか)」が一目で分かります。

この拡張機能を作った動機は、依存関係の連鎖をシンボル単位で可視化したいという点にありました。

できること

  1. カーソルを合わせるだけで依存関係が見える

    • このクラスがどこで使われているか
    • この関数がどこで使われているか
    • この変数が何に依存しているか
    • 循環依存があるかどうか
  2. グラフで視覚的に表示

    • ノードとエッジで依存関係を表現
    • 層ごとに色分け
    • 違反している依存関係は赤で表示
  3. ファイル保存したら自動更新

    • コードを変更したら即座に反映
    • VSCode の再起動不要

実際の動作画面

シンボル単位の依存関係表示(Hover)

image.png
image.png

ポイント

  • シンボル名が先に表示され、パスが括弧書きで表示される
  • 直接依存と間接依存が明確に分かれている
  • 依存の連鎖(深度 1、深度 2)が可視化されている

ファイル単位ではなくシンボル単位

image.png

image.png

ポイント

  • ファイル全体ではなく、個別の関数やクラスごとに依存関係が表示される
  • 同じファイル内でも、シンボルごとに依存先・依存元が異なる

依存関係グラフの全体表示

image.png

ポイント

  • 層ごとに色分けされている(domain: 青、usecases: 緑、presentation: 紫など)
  • ノード間のエッジで依存関係が視覚的に分かる
  • 全体の構造が一目で把握できる

この画像ではクリーンアーキテクチャの例を使っていますが、層の定義は完全にカスタマイズ可能です。設定ファイルで、プロジェクトに合わせて自由に層を定義できます。

循環依存の検出

image.png

ポイント

  • 循環依存(A→B→A)が赤い点線で即座に分かる
  • 「あ、これダメなやつだ」が一瞬で判断できる
  • どこで循環が発生しているかが明確

層違反の検出

image.png
image.png

ポイント

  • 依存の方向が逆流している箇所が赤で警告される
  • アーキテクチャの「一方通行ルール」違反を検出
  • レビュー前に気づける

なぜ作ろうと思ったか

きっかけ

クリーンアーキテクチャのような「一方通行」のアーキテクチャでは、各層が独立しているのは良いのですが、逆に「この変更の影響範囲はどこまで及ぶんだろう?」というのが掴みづらいと感じていました。

例えば、Domain のエンティティを変更したとき

  • どの Repository が影響を受ける?
  • さらにその Repository を使っている UseCase は?
  • その UseCase を呼んでいる Controller は?

という依存の連鎖を手動で追うのが大変で、「これ、自動で可視化できたら便利なのでは?」と思ったのがきっかけです。

解決したかった課題

  • 依存の連鎖が見えない: Ctrl+Click で直接の使用箇所は分かるけど、「その先でさらにどう使われているか」という連鎖が追いにくい
  • 影響範囲の把握が困難: エンティティを変更したとき、どこまで影響が及ぶのか手動で辿るのが大変
  • ファイル単位だと粒度が粗い: 1 つのファイルに複数のクラスや関数がある場合、どのシンボルがどう使われているか分からない
  • ルール違反に気づきにくい: 依存の方向が逆流していても、レビューで指摘されるまで気づけない
  • 循環依存の発見が遅い: 実装してから気づくと修正が大変

だからシンボル単位で、依存の連鎖まで辿れるツールが欲しかった! これが一番のこだわりです。

開発の流れ

1. まずはプロジェクト作成

VSCode 拡張機能を作るには、いくつかの必須ファイルが必要です。

最低限必要なファイル

my-extension/
├── src/
│   └── extension.ts    # メインのロジック
├── package.json        # 拡張機能の設定
└── tsconfig.json       # TypeScript設定

package.jsonには拡張機能の基本情報と、どのコマンドを提供するかを定義します。

{
  "name": "typescript-dependency-visualizer",
  "engines": {
    "vscode": "^1.85.0"
  },
  "activationEvents": ["onLanguage:typescript", "onLanguage:typescriptreact"],
  "main": "./out/extension.js",
  "contributes": {
    "commands": [
      {
        "command": "dependencyVisualizer.showDependencyGraph",
        "title": "Show Dependency Graph"
      },
      {
        "command": "dependencyVisualizer.checkCircularDependencies",
        "title": "Check Circular Dependencies"
      }
    ]
  }
}

公式のジェネレーター(yo code)を使うと楽ですが、今回は自分で設定ファイルを作りながら学ぶことにしました。

2. デバッグ環境の設定

拡張機能を F5 キーでデバッグ実行するには、いくつかの設定ファイルが必要です。

.vscode/launch.json - デバッグ実行の設定

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run Extension",
      "type": "extensionHost",
      "request": "launch",
      "args": ["--extensionDevelopmentPath=${workspaceFolder}"],
      "outFiles": ["${workspaceFolder}/out/**/*.js"],
      "preLaunchTask": "npm: watch"
    }
  ]
}

.vscode/tasks.json - TypeScript のコンパイルタスク

{
  "version": "2.0.0",
  "tasks": [
    {
      "type": "npm",
      "script": "watch",
      "problemMatcher": "$tsc-watch",
      "isBackground": true,
      "presentation": {
        "reveal": "never"
      },
      "group": {
        "kind": "build",
        "isDefault": true
      }
    }
  ]
}

これらの設定により、F5 キーを押すと以下のようになります。

  1. TypeScript が自動でコンパイルされ(watch モード)
  2. 新しい VSCode ウィンドウ(Extension Development Host)が起動
  3. 開発中の拡張機能がテストできる

という流れになります。

3. 拡張機能のエントリーポイント

extension.tsが VSCode 拡張機能のメインファイルです。ここで初期化処理やコマンド登録を行います。

import * as vscode from "vscode";
import { TypeScriptAnalyzer } from "./analyzer/TypeScriptAnalyzer";
import { DependencyHoverProvider } from "./providers/DependencyHoverProvider";

let analyzer: TypeScriptAnalyzer;

export async function activate(context: vscode.ExtensionContext) {
  // ワークスペースルートを取得
  const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
  if (!workspaceFolder) {
    vscode.window.showErrorMessage("ワークスペースフォルダが見つかりません");
    return;
  }

  // TypeScript解析器を初期化
  analyzer = new TypeScriptAnalyzer(workspaceFolder.uri.fsPath);

  // TypeScript/TSXファイルを取得
  const files = await vscode.workspace.findFiles(
    "**/*.{ts,tsx}",
    "**/node_modules/**"
  );
  analyzer.initialize(files.map((uri) => uri.fsPath));

  // HoverProviderを登録
  const hoverProvider = vscode.languages.registerHoverProvider(
    ["typescript", "typescriptreact"],
    new DependencyHoverProvider(analyzer)
  );
  context.subscriptions.push(hoverProvider);

  // コマンド: 依存関係グラフを表示
  const showGraphCommand = vscode.commands.registerCommand(
    "dependencyVisualizer.showDependencyGraph",
    () => {
      // グラフ表示のロジック
      vscode.window.showInformationMessage("依存関係グラフを表示");
    }
  );
  context.subscriptions.push(showGraphCommand);
}

このコードで、VSCode 起動時に自動的に拡張機能が有効化され、TypeScript ファイルを開いた時に依存関係の解析が始まります。

4. TypeScript Compiler API との戦い

ここからが本番です。TypeScript のコードを解析して、シンボル単位の依存関係を取得する必要があります。

最初の壁: Program の作成とシンボルの特定

TypeScript Compiler API にはProgramというものがあって、これがファイル群を解析してくれます。

import * as ts from "typescript";

const files = ["src/file1.ts", "src/file2.ts" /* ... */];
const program = ts.createProgram(files, {
  target: ts.ScriptTarget.ES2020,
  module: ts.ModuleKind.CommonJS,
});

// カーソル位置のシンボルを取得
const sourceFile = program.getSourceFile(fileName);
const node = findNodeAtPosition(sourceFile, position);
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(node);

console.log(symbol.getName()); // "User" とか "getUserById" とか

この拡張機能のキモ

symbolオブジェクトを使えば、個別のクラス・関数・変数を識別できる!
つまり、ファイルではなくシンボル単位で依存関係を追跡できるということです。これがこのツールの核心部分です。

つまずきポイント 1: default export の名前が取れない

通常の named export なら問題なくシンボル名が取得できます。

export class User {} // → "User" が取得できる ✅

しかし、default export だと...

export default class User {} // → "default" になってしまう ❌

このコードでUserにカーソルを合わせると、シンボル名が"default"になってしまう...

解決策

TypeScript の AST(抽象構文木)を辿って、実際の名前を取得する必要がありました。

let symbolName = symbol.getName();

if (symbolName === "default") {
  const declarations = symbol.getDeclarations();
  const declaration = declarations[0];

  // export default User の場合
  if (ts.isExportAssignment(declaration)) {
    symbolName = declaration.expression.text;
  }
  // export default class User の場合
  else if (ts.isClassDeclaration(declaration)) {
    symbolName = declaration.name.text;
  }
}

つまずきポイント 2: Windows のパス問題

開発環境が Windows だったので、パスの扱いで苦労しました。

// これがダメだった例
const relativePath = path.relative("c:\\workspace", "c:/project/file.ts");
// → 期待と違う結果に...

解決策

すべてのパスを正規化してから処理するようにしました。

const normalizedPath1 = path1.replace(/\\/g, "/");
const normalizedPath2 = path2.replace(/\\/g, "/");
const relativePath = path
  .relative(normalizedPath1, normalizedPath2)
  .replace(/\\/g, "/");

補足:後で知ったのですが、Node.js の path.posix を使えばもっとシンプルに書けるそうです。path.posix.relative() は Windows 環境でも Unix スタイル(/)のパスで結果を返してくれるので、手動での正規化が不要になるようです。

つまずきポイント 3: src/domain/**のパターンマッチング

設定ファイルでsrc/domain/**みたいなグロブパターンを使いたかったのですが、***の処理順序が重要だと気づきました。

// ❌ これだとうまくいかない
pattern.replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*");
// 問題: 先に ** を .* に置換すると、.* の中の * が次の処理で [^/]* に置換されてしまう
// 例: "src/**/*.ts" → "src/.*/.ts" → "src/.[^/]*/.ts" (意図しない結果)

// ✅ プレースホルダーを使う
pattern
  .replace(/\*\*/g, "__DOUBLE_STAR__") // 一時退避
  .replace(/\*/g, "[^/]*") // * を処理
  .replace(/__DOUBLE_STAR__/g, ".*"); // ** を復元

補足: 後で知ったのですが、minimatch というライブラリを使えば、グロブパターンのマッチングを簡単に実装できます。実際、VSCode の workspace.findFiles() の内部でも使われているライブラリです。自前で正規表現に変換するよりも、既存のライブラリを活用した方が確実で安全です。

5. グラフ表示にチャレンジ

テキストだけじゃ分かりにくいので、グラフで表示したくなりました。

WebView の実装

VSCode にはWebViewという機能があって、HTML/CSS/JavaScript で自由に UI を作れます。

const panel = vscode.window.createWebviewPanel(
  "dependencyGraph",
  "依存関係グラフ",
  vscode.ViewColumn.Two,
  {
    enableScripts: true, // JavaScriptを有効化
  }
);

panel.webview.html = getWebviewContent();

Cytoscape.js の採用

グラフライブラリを探していて、Cytoscape.js を選びました。ノードとエッジの描画が簡単で、インタラクティブな操作(ドラッグ、ズーム)も標準で提供されていたのが決め手でした。

const cy = cytoscape({
  container: document.getElementById("cy"),
  elements: [
    { data: { id: "User", label: "User" } },
    { data: { id: "UserRepository", label: "UserRepository" } },
    { data: { source: "UserRepository", target: "User" } },
  ],
  style: [
    {
      selector: "node",
      style: {
        "background-color": "#3498db",
        label: "data(label)",
      },
    },
  ],
});

違反を赤く表示

依存関係のルール違反を視覚的に分かりやすくしたかったので、条件分岐で色を変えました。

style: {
    'line-color': function(edge) {
        return edge.data('isViolation') === true ? '#E74C3C' : '#555';
    },
    'line-style': function(edge) {
        return edge.data('isViolation') === true ? 'dashed' : 'solid';
    }
}

赤い点線で表示されると「あ、これダメなやつだ」ってすぐ分かるようにします。

6. 循環依存の検出

循環依存(A→B→A)を見つけるために、DFS(深さ優先探索)を実装しました。

private detectCycles(nodes: any[], edges: any[]): string[][] {
    const visited = new Set<string>();
    const recStack = new Set<string>();  // 再帰スタック
    const path: string[] = [];
    const cycles: string[][] = [];

    const dfs = (nodeId: string) => {
        visited.add(nodeId);
        recStack.add(nodeId);  // 現在の探索パスに追加
        path.push(nodeId);

        const neighbors = getNeighbors(nodeId);
        for (const neighbor of neighbors) {
            if (!visited.has(neighbor)) {
                dfs(neighbor);
            } else if (recStack.has(neighbor)) {
                // 循環発見!
                const cycle = path.slice(path.indexOf(neighbor));
                cycles.push(cycle);
            }
        }

        path.pop();
        recStack.delete(nodeId);  // 探索パスから削除
    };

    nodes.forEach(node => {
        if (!visited.has(node.id)) {
            dfs(node.id);
        }
    });

    return cycles;
}

完成したときの感動

テストアプリで動作確認したとき、ちゃんとシンボル単位で依存関係が表示されて、違反も検出されて、グラフも綺麗に描画されて...「できた!!🎉」ってなりました。

特に感動したポイント

  • カーソルをUserクラスに合わせると、そのクラスだけの依存関係が見える
  • 同じファイルのUserType型に合わせると、また別の依存関係が表示される
  • export されているのに使われていないシンボルが一目で分かる
  • 循環依存が赤い矢印で表示される
  • 層ごとに色分けされて見やすい

「ファイルじゃなくてシンボル単位で見る」という目標が達成できた! これが一番嬉しかったです。

今回実装した機能には、この記事でピックアップした以外にも以下のようなものがあります。

  • ジャンプ機能: 依存先/依存元をクリックでその場所に移動
  • 依存タイプの分類: 型として使用/値として使用/継承/実装の区別
  • node_modules の除外: プロジェクトコードのみを解析対象に
  • ファイル保存時の自動更新: 変更が即座に依存関係に反映
  • キャッシュ機構: 大規模プロジェクトでも快適な解析速度

これらの機能については、記事が長くなってしまうため今回は省略しましたが、機会があればまた別の記事で詳しく紹介できればと思います。

今後やりたいこと

まだまだ改善の余地があります!

  • インポートエイリアス対応: import { User as UserModel } のような別名インポートの依存関係も追跡
  • CodeLens: 関数の上に「使用箇所: 4 件」のような表示
  • メトリクス: 依存数の統計情報
  • エクスポート機能: グラフを PNG/SVG で保存
  • 複数レイアウト: 階層レイアウト以外も選べるように
  • 依存関係の履歴: Git 履歴と連携して変化を追跡
  • レポート生成: プロジェクト全体のアーキテクチャレポート
  • リファクタリング提案: 「この循環依存を解消するには...」みたいな提案

まとめ

VSCode 拡張機能を初めて作ってみて、「意外と作れるんだ!」というのが率直な感想です。

最初は不安でしたが、小さく始めて徐々に機能を追加していくことで、実用的なツールを作ることができました。

一番のこだわりポイント

「ファイル単位ではなく、シンボル単位(関数・クラス・変数)で依存関係を見る」

これを実現できたことで、より細かく、より実用的な依存関係の可視化ができました。

開発のモチベーション

「こういうツールがあったらいいな」という気持ちがあれば、VSCode 拡張機能として形にできる可能性は十分あります!

特に「既存のツールはファイル単位だけど、自分はシンボル単位で見たい」みたいなこだわりがあると、それがモチベーションになって最後まで作りきれます。

皆さんも、普段の開発で「これ、自動化できたら便利なのに...」と思うことがあれば、ぜひ VSCode 拡張機能を作ってみてください。

参考にしたリソース

公式ドキュメント

おわりに

最後まで読んでいただきありがとうございました!

この記事が、VSCode 拡張機能開発に興味を持っている方の参考になれば嬉しいです。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?