iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0
AI Engineering

30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰系列 第 21 篇

Day 21:從變更分析開始:定義呼叫邊(Call Edge)與資料模型

  • 分享至 

  • xImage
  •  

前面我們已經能做到精準搜尋與 Rerank,但當工程師問「如果我改了這個函式,會影響誰」時,檢索系統就不能只看整份檔案的 import,必須精確到「函式呼叫函式」的粒度。

今天動手做變更影響分析打底:定義呼叫圖(Call Graph)的核心資料結構,並在 SQLite 中開闢保存呼叫關係的專屬資料表。

呼叫關係的最小單元:CallEdge

一次函式呼叫(Call)至少需要記錄四個維度:

  1. 呼叫端(Caller):誰發起了呼叫(包含檔案路徑與函式名稱)。
  2. 被呼叫端(Callee):呼叫了什麼名稱(AST 看到的目標函式名)。
  3. 精確位置(Location):這次呼叫發生在哪個檔案的第幾行(維持 Evidence First 原則)。
  4. 解析狀態(Resolution Status):靜態分析時是否已經能對應到真實目標檔案。
from dataclasses import dataclass
from typing import Optional

@dataclass
class CallEdge:
    caller_path: str        # 呼叫端檔案,如 "src/rag_chat.py"
    caller_symbol: str      # 呼叫端函式,如 "chat_loop"
    callee_name: str        # 被呼叫名稱,如 "build_index" 或 "client.search"
    line: int               # 呼叫發生的行號
    callee_path: Optional[str] = None   # 解析後的被呼叫端檔案 (若能確定)
    callee_symbol: Optional[str] = None # 解析後的完整符號 (若能確定)


擴充 SQLite Schema:calls 表

我們在 app/codebase.py 中擴充 SQLite 資料庫,建立專門存放跨函式呼叫的 calls 表:

CREATE TABLE IF NOT EXISTS calls (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    caller_path TEXT NOT NULL,
    caller_symbol TEXT NOT NULL,
    callee_name TEXT NOT NULL,
    callee_path TEXT,
    callee_symbol TEXT,
    line INTEGER NOT NULL
);

CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_path, caller_symbol);
CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_name);

核心實作:app/call_graph.py 基礎骨架

建立 app/call_graph.py,負責呼叫邊的寫入與基礎查詢:

# app/call_graph.py
import sqlite3
from typing import List, Dict, Any, Optional
from dataclasses import dataclass

@dataclass
class CallEdge:
    caller_path: str
    caller_symbol: str
    callee_name: str
    line: int
    callee_path: Optional[str] = None
    callee_symbol: Optional[str] = None

class CallGraphStore:
    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn
        self._init_table()

    def _init_table(self):
        with self.conn:
            self.conn.executescript("""
                CREATE TABLE IF NOT EXISTS calls (
                    id INTEGER PRIMARY KEY AUTOINCREMENT,
                    caller_path TEXT NOT NULL,
                    caller_symbol TEXT NOT NULL,
                    callee_name TEXT NOT NULL,
                    callee_path TEXT,
                    callee_symbol TEXT,
                    line INTEGER NOT NULL
                );
                CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_path, caller_symbol);
                CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_name);
            """)

    def insert_edges(self, edges: List[CallEdge]):
        """批次寫入呼叫邊"""
        with self.conn:
            rows = [
                (e.caller_path, e.caller_symbol, e.callee_name, e.callee_path, e.callee_symbol, e.line)
                for e in edges
            ]
            self.conn.executemany("""
                INSERT INTO calls (caller_path, caller_symbol, callee_name, callee_path, callee_symbol, line)
                VALUES (?, ?, ?, ?, ?, ?)
            """, rows)

    def find_direct_callers_by_name(self, callee_name: str) -> List[Dict[str, Any]]:
        """給定被呼叫函式名稱,逆向找出所有直接呼叫它的 caller 與行號"""
        cur = self.conn.cursor()
        cur.execute("""
            SELECT caller_path, caller_symbol, callee_name, line
            FROM calls
            WHERE callee_name = ?
            ORDER BY caller_path, line
        """, (callee_name,))
        return [dict(row) for row in cur.fetchall()]

實作單元測試:tests/unit/test_call_graph_store.py

在 tests/unit/test_call_graph_store.py 驗證呼叫關係的寫入與逆向反查:

# tests/unit/test_call_graph_store.py
import sqlite3
from app.call_graph import CallGraphStore, CallEdge

def test_insert_and_find_callers():
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    store = CallGraphStore(conn)

    # 模擬兩處呼叫了 build_index()
    edges = [
        CallEdge(caller_path="src/cli.py", caller_symbol="main", callee_name="build_index", line=45),
        CallEdge(caller_path="src/test_pipeline.py", caller_symbol="test_run", callee_name="build_index", line=12),
        CallEdge(caller_path="src/cli.py", caller_symbol="main", callee_name="setup_logging", line=10)
    ]
    store.insert_edges(edges)

    # 逆向查詢誰呼叫了 build_index
    callers = store.find_direct_callers_by_name("build_index")

    assert len(callers) == 2
    assert callers[0]["caller_path"] == "src/cli.py"
    assert callers[0]["caller_symbol"] == "main"
    assert callers[0]["line"] == 45
    assert callers[1]["caller_path"] == "src/test_pipeline.py"

執行測試確認綠燈:

uv run pytest tests/unit/test_call_graph_store.py -v


```text
tests/unit/test_call_graph_store.py::test_insert_and_find_callers PASSED  [100%]
============================== 1 passed in 0.04s ==============================


下一步

今天完成了呼叫圖的資料底層與反查結構。

明天,我們將直接進入 AST 解析器,走訪 Python 語法樹的 ast.Call 節點,把專案中所有真實發生的函式呼叫事件一次全部抓出來!


上一篇
Day 20:Hybrid Search vs LLM Reranker 量化對比與延遲分析
下一篇
Day 22:從 AST 找出函式呼叫:走訪 ast.Call 節點建立呼叫邊
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言