前面我們已經能做到精準搜尋與 Rerank,但當工程師問「如果我改了這個函式,會影響誰」時,檢索系統就不能只看整份檔案的 import,必須精確到「函式呼叫函式」的粒度。
今天動手做變更影響分析打底:定義呼叫圖(Call Graph)的核心資料結構,並在 SQLite 中開闢保存呼叫關係的專屬資料表。
CallEdge一次函式呼叫(Call)至少需要記錄四個維度:
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 # 解析後的完整符號 (若能確定)
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 節點,把專案中所有真實發生的函式呼叫事件一次全部抓出來!