昨天我們走訪語法樹抓出了 42 處呼叫邊,但現在這些邊大多只記錄了未解析的短名稱(Short Name):
CallEdge(caller_path="src/cli.py", caller_symbol="main", callee_name="build_index", ...)
在呼叫端 cli.py 的程式碼裡,它只寫了 build_index()。
但如果要回答「改動 src/build_index.py 會影響誰」,呼叫圖就必須能確認:這個 build_index 到底對應到哪一個檔案裡的實體定義?
今天我們要實作 符號解析器(Symbol Resolver),把短名稱連結到 Day 10 存進 SQLite 的真實 Symbol 定義中。
靜態分析最容易踩的坑是「過度自信」:看到呼叫端寫 load(),就隨便在專案裡找第一個叫 load() 的函式連上去。
如果一個專案裡有 5 個檔案各自定義了 load(),而我們在缺乏型別與作用域推導的情況下硬猜,產生的呼叫圖就會充滿錯誤的幽靈連線,後續的變更影響分析也會直接失真。
因此,本篇的解析器遵守兩條鋼鐵規則:
(target_path, qualified_name)。SymbolResolver在 app/call_graph.py 中實作跨檔案符號解析邏輯:
# app/call_graph.py (擴充符號解析器)
from typing import Dict, List, Optional, Tuple
import sqlite3
class SymbolResolver:
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
self._symbol_cache: Dict[str, List[Dict[str, str]]] = {}
self._load_symbol_definitions()
def _load_symbol_definitions(self):
"""一次性快取專案中所有頂層定義符號,減少逐次查詢 DB 負擔"""
cur = self.conn.cursor()
cur.execute("SELECT path, name, qualified_name, kind FROM symbols")
for row in cur.fetchall():
name = row["name"]
if name not in self._symbol_cache:
self._symbol_cache[name] = []
self._symbol_cache[name].append({
"path": row["path"],
"qualified_name": row["qualified_name"],
"kind": row["kind"]
})
def resolve_call(self, caller_path: str, callee_name: str) -> Tuple[Optional[str], Optional[str]]:
"""
將 callee_name 解析為 (callee_path, callee_symbol)。
若無法唯一確定,回傳 (None, None)。
"""
# 1. 處理自身檔案內的同名函式/類別優先權
candidates = self._symbol_cache.get(callee_name, [])
local_matches = [c for c in candidates if c["path"] == caller_path]
if len(local_matches) == 1:
return local_matches[0]["path"], local_matches[0]["qualified_name"]
# 2. 跨檔案唯一符號解析
if len(candidates) == 1:
# 全專案僅此一家,安全連結
return candidates[0]["path"], candidates[0]["qualified_name"]
# 3. 歧義情境:存在多個同名定義,保守不猜
return None, None
tests/unit/test_symbol_resolver.py在 tests/unit/test_symbol_resolver.py 驗證唯一解析與歧義防禦:
# tests/unit/test_symbol_resolver.py
import sqlite3
from app.codebase import SymbolIndexer
from app.call_graph import SymbolResolver
from dataclasses import dataclass
@dataclass
class DummySymbol:
path: str
kind: str
name: str
qualified_name: str
start_line: int
end_line: int
docstring: str = ""
def test_resolve_unique_cross_file_symbol():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
indexer = SymbolIndexer(db_path=":memory:")
indexer.conn = conn
indexer._init_db()
# 專案中只有唯一的 build_index
symbols = [
DummySymbol("src/build_index.py", "function", "build_index", "build_index", 10, 30),
DummySymbol("src/rag_common.py", "function", "get_env", "get_env", 5, 10),
]
indexer.rebuild(symbols=symbols, imports=[])
resolver = SymbolResolver(conn)
# 在 cli.py 呼叫 build_index,應唯一解析至 src/build_index.py
path, qname = resolver.resolve_call(caller_path="src/cli.py", callee_name="build_index")
assert path == "src/build_index.py"
assert qname == "build_index"
def test_conservative_on_duplicate_ambiguity():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
indexer = SymbolIndexer(db_path=":memory:")
indexer.conn = conn
indexer._init_db()
# 兩個不同檔案都有 load 函式
symbols = [
DummySymbol("src/service.py", "function", "load", "load", 1, 5),
DummySymbol("src/storage.py", "function", "load", "load", 1, 5),
]
indexer.rebuild(symbols=symbols, imports=[])
resolver = SymbolResolver(conn)
# 在第三個檔案呼叫 load,由於有歧義,必須保守回傳 (None, None)
path, qname = resolver.resolve_call(caller_path="src/main.py", callee_name="load")
assert path is None
assert qname is None
執行測試確認通過:
uv run pytest tests/unit/test_symbol_resolver.py -v
tests/unit/test_symbol_resolver.py::test_resolve_unique_cross_file_symbol PASSED [ 50%]
tests/unit/test_symbol_resolver.py::test_conservative_on_duplicate_ambiguity PASSED [100%]
============================== 2 passed in 0.04s ==============================
mobileai-local-rag 批次解析呼叫邊將 Resolver 套用到昨天萃取的 42 條呼叫邊上,更新資料庫:
uv run python -m app.cli resolve-calls
終端機輸出報告:
{
"total_call_edges": 42,
"uniquely_resolved": 29,
"ambiguous_or_builtins": 13,
"status": "ready"
}
在受測專案中,有 29 處跨檔案呼叫被 100% 確定性地對應到了真實檔案與符號;另外 13 處(包含內建函式 print、open 或未定名的物件方法)則被安全保留,沒有產生任何錯誤連線。
今天我們解決了靜態呼叫關係中最危險的「跨檔案連結」問題:
但那 13 處未解析的呼叫邊真的沒救了嗎?
不!如果呼叫端開頭寫了 from service import load,我們就能利用 Import 上下文徹底消除同名歧義!
明天在 Day 24 中,我們將結合 Day 10 的 Import 資料庫,打造 Import-Aware 呼叫圖解析,把引用關係與呼叫邊完美扣合!