iT邦幫忙

2026 iThome 鐵人賽

DAY 23
0

昨天我們走訪語法樹抓出了 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(),而我們在缺乏型別與作用域推導的情況下硬猜,產生的呼叫圖就會充滿錯誤的幽靈連線,後續的變更影響分析也會直接失真。

因此,本篇的解析器遵守兩條鋼鐵規則:

  1. 唯一性解析(Unambiguous Resolution):若全專案中只有唯一的檔案定義了該符號,且該符號為頂層函式或類別,則安全解析為 (target_path, qualified_name)。
  2. 同名歧義保留(Conservative Fallback):若全專案有多個同名定義,且當前檔案沒有直接同名宣告,刻意不瞎猜,標記為未解析(Unresolved)並保留原始短名稱,等待 Day 24 的 Import 關係進一步澄清。

核心實作: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 或未定名的物件方法)則被安全保留,沒有產生任何錯誤連線。

總結與下一步

今天我們解決了靜態呼叫關係中最危險的「跨檔案連結」問題:

  1. 唯一性鎖定:安全將短名稱還原成跨模組的實體路徑。
  2. 保守退避:遭遇同名衝突時絕不胡亂瞎猜,寧可留白也不捏造錯誤的依賴。

但那 13 處未解析的呼叫邊真的沒救了嗎?

不!如果呼叫端開頭寫了 from service import load,我們就能利用 Import 上下文徹底消除同名歧義!

明天在 Day 24 中,我們將結合 Day 10 的 Import 資料庫,打造 Import-Aware 呼叫圖解析,把引用關係與呼叫邊完美扣合!


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

尚未有邦友留言

立即登入留言