UNPKG

symref

Version:

Static code checker for AI code agents (Windsurf, Cline, etc.)

678 lines 28.7 kB
import { SyntaxKind } from 'ts-morph'; import * as path from 'node:path'; import * as fs from 'node:fs'; import { NodeUtils } from '../utils/NodeUtils.js'; /** * 呼び出しグラフの構築と分析を担当するクラス */ export class CallGraphAnalyzer { /** * コンストラクタ * @param project ts-morphのプロジェクトインスタンス * @param outputDir 出力ディレクトリ(オプション) */ constructor(project, outputDir = '.symbols') { this.project = project; this.nodeUtils = new NodeUtils(); this.callGraph = new Map(); this.outputDir = outputDir; this.ensureOutputDir(); } /** * 出力ディレクトリを確保 */ ensureOutputDir() { if (!fs.existsSync(this.outputDir)) { fs.mkdirSync(this.outputDir, { recursive: true }); } // .gitignoreファイルを作成 const gitignorePath = path.join(this.outputDir, '.gitignore'); if (!fs.existsSync(gitignorePath)) { fs.writeFileSync(gitignorePath, '*\n'); } } /** * グラフファイルの出力パスを生成 * @param baseName 基本ファイル名 * @returns 出力パス */ generateOutputPath(baseName) { const now = new Date(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, '0'); const day = String(now.getDate()).padStart(2, '0'); const hours = String(now.getHours()).padStart(2, '0'); const minutes = String(now.getMinutes()).padStart(2, '0'); const timestamp = `${year}${month}${day}_${hours}${minutes}`; const safeBaseName = baseName.replace(/[^a-zA-Z0-9]/g, '_'); const fileName = `${safeBaseName}_${timestamp}.md`; return path.join(this.outputDir, fileName); } /** * プロジェクト全体の呼び出しグラフを構築 * @returns 構築された呼び出しグラフのノード数 */ buildCallGraph() { // 既存のグラフをクリア this.callGraph.clear(); // プロジェクト内のすべてのソースファイルを処理 for (const sourceFile of this.project.getSourceFiles()) { // .d.tsファイルはスキップ if (sourceFile.getFilePath().endsWith('.d.ts')) continue; // 関数宣言を処理 this.processFunctions(sourceFile); // クラス宣言を処理 this.processClasses(sourceFile); // 変数宣言されたコンポーネントを処理 this.processVariableDeclarations(sourceFile); } // 呼び出し関係を構築 this.buildCallRelationships(); return this.callGraph.size; } /** * 関数宣言を処理 * @param sourceFile ソースファイル */ processFunctions(sourceFile) { const functions = sourceFile.getFunctions(); for (const func of functions) { const funcName = func.getName(); if (!funcName) continue; // 無名関数はスキップ // ノードを作成または取得 const node = this.getOrCreateNode(funcName, 'function', func); // 関数本体内の呼び出しを処理 this.processCallExpressions(func, node); } } /** * クラス宣言を処理 * @param sourceFile ソースファイル */ processClasses(sourceFile) { const classes = sourceFile.getClasses(); for (const classDecl of classes) { const className = classDecl.getName(); if (!className) continue; // クラスノードを作成 this.getOrCreateNode(className, 'class', classDecl); // メソッドを処理 const methods = classDecl.getMethods(); for (const method of methods) { const methodName = method.getName(); if (!methodName) continue; const fullMethodName = `${className}.${methodName}`; const methodNode = this.getOrCreateNode(fullMethodName, 'method', method); // メソッド本体内の呼び出しを処理 this.processCallExpressions(method, methodNode); } } } /** * 変数宣言を処理し、Reactコンポーネントをグラフに追加 * @param sourceFile ソースファイル */ processVariableDeclarations(sourceFile) { const varDecls = sourceFile.getVariableDeclarations(); for (const varDecl of varDecls) { const varName = varDecl.getName(); if (!varName) continue; // シンボルタイプを判定 const definitionNode = varDecl.getNameNode(); if (!definitionNode) continue; const symbolType = this.nodeUtils.determineSymbolType(definitionNode); // 関数コンポーネントまたはクラスコンポーネントの場合のみグラフに追加 if (symbolType === 'function-component' || symbolType === 'class-component') { const node = this.getOrCreateNode(varName, symbolType, definitionNode); const initializer = varDecl.getInitializer(); if (initializer) { // コンポーネントの初期化式(関数本体など)内の呼び出しを処理 this.processCallExpressions(initializer, node); } } } } /** * 関数/メソッド本体内の呼び出し式を処理 * @param node 関数/メソッドノード * @param callGraphNode 呼び出しグラフノード */ processCallExpressions(node, callGraphNode) { // 関数/メソッド本体内のすべての呼び出し式を取得 const callExpressions = node.getDescendantsOfKind(SyntaxKind.CallExpression); for (const callExpr of callExpressions) { const expression = callExpr.getExpression(); // 直接呼び出し (例: myFunction()) if (expression.isKind(SyntaxKind.Identifier)) { const calleeName = expression.getText(); // React Hookパターンをチェック (useXxx) if (calleeName.startsWith('use') && /^use[A-Z]/.test(calleeName)) { this.recordHookCallRelationship(callGraphNode, calleeName, callExpr); continue; } // 通常の関数呼び出し this.recordCallRelationship(callGraphNode, calleeName, callExpr); } // プロパティアクセス呼び出し (例: obj.method(), React.useState()) else if (expression.isKind(SyntaxKind.PropertyAccessExpression)) { const propAccess = expression; const methodName = propAccess.getName(); const objExpr = propAccess.getExpression(); // React.useXxx() パターンをチェック const objText = objExpr.getText(); if (methodName && objText === 'React' && methodName.startsWith('use') && /^use[A-Z]/.test(methodName)) { this.recordHookCallRelationship(callGraphNode, `React.${methodName}`, callExpr); continue; } // 型に基づく完全な解決が難しいため、テキストベースでの解決を主とする if (methodName) { const fullMethodName = `${objText}.${methodName}`; this.recordCallRelationship(callGraphNode, fullMethodName, callExpr); } } } // JSX 要素 (コンポーネントの使用) を処理 const jsxElements = [ ...node.getDescendantsOfKind(SyntaxKind.JsxOpeningElement), ...node.getDescendantsOfKind(SyntaxKind.JsxSelfClosingElement) ]; for (const jsxElement of jsxElements) { const tagNameNode = jsxElement.getTagNameNode(); let calleeName; if (tagNameNode.isKind(SyntaxKind.Identifier)) { calleeName = tagNameNode.getText(); } else if (tagNameNode.isKind(SyntaxKind.PropertyAccessExpression)) { // 例: <Namespace.Component /> calleeName = tagNameNode.getText(); // フルネームを取得 } if (calleeName) { // コンポーネント名はPascalCaseであることを確認(HTMLタグとの区別) if (calleeName[0] === calleeName[0].toUpperCase()) { // 呼び出し元 (callGraphNode.symbol) から calleeName への関係を記録 this.recordCallRelationship(callGraphNode, calleeName, jsxElement); } } } } /** * 呼び出し関係を記録 * @param caller 呼び出し元ノード * @param calleeName 呼び出し先シンボル名 * @param callNode 呼び出し箇所のノード(オプション) */ recordCallRelationship(caller, calleeName, callNode) { // 呼び出し先ノードを取得または作成 let calleeType = 'unknown'; // シンボルタイプを特定 const sourceFiles = this.project.getSourceFiles(); for (const file of sourceFiles) { // 関数コンポーネントを探す const funcDecls = file.getFunctions(); for (const func of funcDecls) { if (func.getName() === calleeName) { calleeType = this.nodeUtils.determineSymbolType(func.getNameNode() || func); break; } } // 変数宣言のコンポーネントを探す if (calleeType === 'unknown') { const varDecls = file.getVariableDeclarations(); for (const varDecl of varDecls) { if (varDecl.getName() === calleeName) { calleeType = this.nodeUtils.determineSymbolType(varDecl.getNameNode() || varDecl); break; } } } // クラスコンポーネントを探す if (calleeType === 'unknown') { const classDecls = file.getClasses(); for (const classDecl of classDecls) { if (classDecl.getName() === calleeName) { calleeType = this.nodeUtils.determineSymbolType(classDecl.getNameNode() || classDecl); break; } } } if (calleeType !== 'unknown') break; } // コンポーネント名はPascalCaseであることが多いため、型の推測を試みる if (calleeType === 'unknown' && calleeName[0] === calleeName[0].toUpperCase() && /[A-Z][a-z]+/.test(calleeName)) { calleeType = 'potential-component'; } const calleeNode = this.getOrCreateNode(calleeName, calleeType, null); // 呼び出し位置情報を取得 let callLocation = caller.location; if (callNode) { try { callLocation = { filePath: path.relative(process.cwd(), callNode.getSourceFile().getFilePath()), line: callNode.getStartLineNumber(), column: callNode.getStartLinePos(), context: this.nodeUtils.getNodeContext(callNode) }; } catch (error) { console.warn(`警告: 呼び出し位置情報を取得できませんでした。`); } } // エッジ情報を作成 const edge = { caller, callee: calleeNode, location: callLocation }; // 呼び出し関係を記録 calleeNode.callers.push(caller); caller.callees.push(calleeNode); } /** * React Hook呼び出し関係を記録 * @param caller 呼び出し元ノード(コンポーネント) * @param hookName フック名 * @param callNode 呼び出し箇所のノード */ recordHookCallRelationship(caller, hookName, callNode) { // フックノードを作成または取得 const hookNode = this.getOrCreateNode(hookName, 'react-hook', null); // 呼び出し位置情報を取得 let callLocation = caller.location; if (callNode) { try { callLocation = { filePath: path.relative(process.cwd(), callNode.getSourceFile().getFilePath()), line: callNode.getStartLineNumber(), column: callNode.getStartLinePos(), context: `React Hook: ${this.nodeUtils.getNodeContext(callNode)}` }; } catch (error) { console.warn(`警告: フック呼び出し位置情報を取得できませんでした。`); } } // エッジ情報を作成 const edge = { caller, callee: hookNode, location: callLocation }; // 呼び出し関係を記録 hookNode.callers.push(caller); caller.callees.push(hookNode); } /** * 呼び出し関係を構築 */ buildCallRelationships() { // すべてのノードを処理 for (const [symbolName, node] of this.callGraph.entries()) { // 関数コンポーネントとクラスコンポーネントの場合、特別な処理 if (node.type === 'function-component' || node.type === 'class-component') { // このコンポーネントがJSX内で使用するコンポーネントを確認 // 基本的な関係は既にprocessCallExpressionsで構築されているので、 // ここでは必要に応じて追加の関係を構築 // 例:パターンベースのコンポーネント検出の強化 for (const callee of node.callees) { if (callee.type === 'unknown' && callee.symbol[0] === callee.symbol[0].toUpperCase() && /[A-Z][a-z]+/.test(callee.symbol)) { callee.type = 'potential-component'; } } } // unknown型のノードがPascalCaseの場合、潜在的なコンポーネントとして検出 else if (node.type === 'unknown' && symbolName[0] === symbolName[0].toUpperCase() && /[A-Z][a-z]+/.test(symbolName)) { node.type = 'potential-component'; } } } /** * ノードを取得または作成 * @param symbolName シンボル名 * @param type シンボルタイプ * @param node ノード(オプション) * @returns 呼び出しグラフノード */ getOrCreateNode(symbolName, type, node) { if (this.callGraph.has(symbolName)) { const existingNode = this.callGraph.get(symbolName); // ノードが存在するが位置情報がない場合は更新 if (node && (!existingNode.location.filePath || existingNode.location.line === 0)) { existingNode.location = { filePath: path.relative(process.cwd(), node.getSourceFile().getFilePath()), line: node.getStartLineNumber(), column: node.getStartLinePos(), context: this.nodeUtils.getNodeContext(node) }; } return existingNode; } let location = { filePath: '', line: 0, column: 0, context: '' }; if (node) { try { const sourceFile = node.getSourceFile(); location = { filePath: path.relative(process.cwd(), sourceFile.getFilePath()), line: node.getStartLineNumber(), column: node.getStartLinePos(), context: this.nodeUtils.getNodeContext(node) }; } catch (error) { console.warn(`警告: シンボル '${symbolName}' の位置情報を取得できませんでした。`); } } const graphNode = { symbol: symbolName, type, location, callers: [], callees: [] }; this.callGraph.set(symbolName, graphNode); return graphNode; } /** * 2つのシンボル間の呼び出し経路を検索 * @param fromSymbol 開始シンボル * @param toSymbol 終了シンボル * @returns 呼び出し経路の分析結果 */ findPathsFromTo(fromSymbol, toSymbol) { // グラフが構築されていない場合は構築 if (this.callGraph.size === 0) { this.buildCallGraph(); } const startNode = this.callGraph.get(fromSymbol); const endNode = this.callGraph.get(toSymbol); if (!startNode) { throw new Error(`開始シンボル '${fromSymbol}' が見つかりません。`); } if (!endNode) { throw new Error(`終了シンボル '${toSymbol}' が見つかりません。`); } // 深さ優先探索で経路を検索 const paths = []; const visited = new Set(); const currentPath = []; const currentEdges = []; this.dfsSearch(startNode, endNode, visited, currentPath, currentEdges, paths); // グラフを生成 const { content, outputPath } = this.generateMermaidFormat(paths, `trace_${fromSymbol}_to_${toSymbol}`); return { paths, totalPaths: paths.length, graphMermaidFormat: content, outputPath }; } /** * シンボルを呼び出すすべての経路を検索 * @param symbol 対象シンボル * @returns 呼び出し経路の分析結果 */ findAllCallers(symbol) { // グラフが構築されていない場合は構築 if (this.callGraph.size === 0) { this.buildCallGraph(); } const targetNode = this.callGraph.get(symbol); if (!targetNode) { throw new Error(`シンボル '${symbol}' が見つかりません。`); } // 逆方向の深さ優先探索で経路を検索 const paths = []; // すべての呼び出し元を処理 for (const caller of targetNode.callers) { const visited = new Set(); const currentPath = [targetNode]; const currentEdges = []; this.dfsReverseSearch(caller, visited, currentPath, currentEdges, paths); } // グラフを生成 const { content, outputPath } = this.generateMermaidFormat(paths, `callers_${symbol}`); return { paths, totalPaths: paths.length, graphMermaidFormat: content, outputPath }; } /** * 深さ優先探索で経路を検索 * @param current 現在のノード * @param target 目標ノード * @param visited 訪問済みノード * @param path 現在の経路 * @param edges 現在の経路のエッジ * @param results 結果の経路リスト */ dfsSearch(current, target, visited, path, edges, results) { // 現在のノードを経路に追加 path.push(current); visited.add(current.symbol); // 目標に到達した場合 if (current.symbol === target.symbol) { // 経路をコピーして結果に追加 results.push({ nodes: [...path], edges: [...edges], startSymbol: path[0].symbol, endSymbol: current.symbol }); } else { // 呼び出し先を探索 for (const callee of current.callees) { if (!visited.has(callee.symbol)) { // エッジを作成 const edge = { caller: current, callee, location: callee.location }; edges.push(edge); // 再帰的に探索 this.dfsSearch(callee, target, visited, path, edges, results); // バックトラック edges.pop(); } } } // バックトラック path.pop(); visited.delete(current.symbol); } /** * 逆方向の深さ優先探索で経路を検索 * @param current 現在のノード * @param visited 訪問済みノード * @param path 現在の経路 * @param edges 現在の経路のエッジ * @param results 結果の経路リスト */ dfsReverseSearch(current, visited, path, edges, results) { // ここでcircular reference対策 if (visited.has(current.symbol)) { // 循環参照を検出した場合は処理をスキップ return; } // 新しいパスと辺インスタンスを作成 const newPath = [...path, current]; const newVisited = new Set(visited); newVisited.add(current.symbol); // 呼び出し元がない場合(=根ノード)、現在のパスを結果に追加 if (!current.callers || current.callers.length === 0) { results.push({ nodes: [...newPath], edges: [...edges], startSymbol: newPath[0].symbol, endSymbol: newPath[newPath.length - 1].symbol }); return; } // 各呼び出し元を再帰的に処理 for (const caller of current.callers) { // 既に訪問済みの呼び出し元はスキップ if (visited.has(caller.symbol)) { continue; } // エッジを追加して再帰呼び出し const newEdges = [...edges]; newEdges.unshift({ caller: caller, callee: current, location: caller.location }); this.dfsReverseSearch(caller, newVisited, newPath, newEdges, results); } } /** * Mermaid形式のグラフを生成 * @param paths 経路リスト * @param baseName 基本ファイル名 * @returns Mermaid形式の文字列と出力パス */ generateMermaidFormat(paths, baseName) { let mermaid = '```mermaid\n'; mermaid += 'classDiagram\n'; // コンポーネントとメソッドの関係を整理 const components = new Set(); const hooks = new Set(); const classMethods = new Map(); const methodCalls = new Set(); const componentCalls = new Set(); const hookCalls = new Set(); // ノードとエッジを収集 for (const path of paths) { for (const node of path.nodes) { // コンポーネントの場合 if (node.type === 'function-component' || node.type === 'class-component' || node.type === 'potential-component') { components.add(node.symbol); } // Reactフックの場合 if (node.type === 'react-hook') { hooks.add(node.symbol); } // クラスメソッドの場合 const [className, methodName] = node.symbol.split('.'); if (methodName) { // クラスとメソッドの関係を記録 if (!classMethods.has(className)) { classMethods.set(className, new Set()); } classMethods.get(className).add(methodName); } } for (const edge of path.edges) { const caller = edge.caller; const callee = edge.callee; // コンポーネントからフックへの呼び出し関係を記録 if ((caller.type === 'function-component' || caller.type === 'class-component' || caller.type === 'potential-component') && callee.type === 'react-hook') { hookCalls.add(`${caller.symbol} --> ${callee.symbol} : uses hook`); } // コンポーネント間の呼び出し関係を記録 else if ((caller.type === 'function-component' || caller.type === 'class-component' || caller.type === 'potential-component') && (callee.type === 'function-component' || callee.type === 'class-component' || callee.type === 'potential-component')) { componentCalls.add(`${caller.symbol} --> ${callee.symbol} : uses`); } // メソッド間の呼び出し関係 const [callerClass, callerMethod] = caller.symbol.split('.'); const [calleeClass, calleeMethod] = callee.symbol.split('.'); if (callerMethod && calleeMethod) { methodCalls.add(`${callerClass}.${callerMethod} --> ${calleeClass}.${calleeMethod}`); } } } // コンポーネントを出力 for (const component of components) { const node = this.callGraph.get(component); if (node) { if (node.type === 'function-component') { mermaid += ` class ${component} {\n <<Function Component>>\n }\n`; } else if (node.type === 'class-component') { mermaid += ` class ${component} {\n <<Class Component>>\n }\n`; } else { mermaid += ` class ${component} {\n <<Component>>\n }\n`; } } } // Reactフックを出力 for (const hook of hooks) { mermaid += ` class ${hook} {\n <<React Hook>>\n }\n`; } // クラスとメソッドを出力 for (const [className, methods] of classMethods) { // 既にコンポーネントとして出力済みの場合はスキップ if (components.has(className)) continue; mermaid += ` class ${className} {\n`; for (const method of methods) { mermaid += ` +${method}()\n`; } mermaid += ' }\n'; } // ノードのスタイル設定 for (const component of components) { const node = this.callGraph.get(component); if (node) { if (node.type === 'function-component') { mermaid += ` style ${component} fill:#d0e0ff,stroke:#3366cc\n`; } else if (node.type === 'class-component') { mermaid += ` style ${component} fill:#d6efd0,stroke:#339933\n`; } } } // Reactフックのスタイル設定 for (const hook of hooks) { mermaid += ` style ${hook} fill:#ffe6cc,stroke:#ff9933\n`; } // コンポーネント間の呼び出し関係を出力 for (const call of componentCalls) { mermaid += ` ${call}\n`; } // フック呼び出し関係を出力 for (const call of hookCalls) { mermaid += ` ${call}\n`; } // メソッド間の呼び出し関係を出力 for (const call of methodCalls) { mermaid += ` ${call}\n`; } mermaid += '```\n'; // 出力パスを生成 const outputPath = this.generateOutputPath(baseName); return { content: mermaid, outputPath }; } } //# sourceMappingURL=CallGraphAnalyzer.js.map