iT邦幫忙

2026 iThome 鐵人賽

DAY 24
0

昨天我們訂下了「寧缺毋濫、錯的圖比不完整的圖更危險」的原則,對同名符號(例如多個檔案都有 load())採取保守不猜的策略。

但這也帶來了一個代價:專案中原本正常的呼叫,常常因為名稱常見而被標記為未解析(Unresolved)。

其實 Python 原始碼早就留下了線索——檔案頂層的 import 宣告:

# src/main.py
from service import load

def run():
    load() # 這裡的 load 顯然來自 service,而不是 storage!

今天我們要結合 Day 10 存進 SQLite 的 imports 資料表,打造 Import-Aware(具備匯入感知)的呼叫圖解析器,用模組上下文精準破除同名歧義!

解析邏輯:三層優先權過濾

當在 caller_path 遇到一個函式呼叫 callee_name 時,解析器依序檢查:

  1. 第 1 層:直接 ImportFrom 解析(精準鎖定)
    檢查該檔案是否有 from <module> import <callee_name> 或別名宣告。若有,直接鎖定目標模組,即使專案其他地方有 10 個同名函式也能唯一解析。

  2. 第 2 層:模組屬性呼叫(Module Attribute Call)
    若呼叫形式為 service.load(),檢查該檔案是否有 import service。若有,直接將目標解析為 service 模組下的 load。

  3. 第 3 層:全專案唯一符號退避(Day 23 邏輯)
    若前兩層皆無宣告,才檢查該符號是否在全專案唯一存在;若存在多處同名定義,則維持未解析。

核心實作:ImportAwareResolver

我們在 app/call_graph.py 中擴充具備 Import 感知的解析器:

# app/call_graph.py (擴充 Import-Aware 解析邏輯)
import sqlite3
from typing import Dict, List, Optional, Tuple

class ImportAwareResolver:
    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn
        self._import_cache: Dict[str, List[Dict[str, Any]]] = {}
        self._symbol_cache: Dict[str, List[Dict[str, Any]]] = {}
        self._load_caches()

    def _load_caches(self):
        cur = self.conn.cursor()
        
        # 載入所有 import 關係
        cur.execute("SELECT from_path, module_name, imported_name, alias FROM imports")
        for r in cur.fetchall():
            path = r["from_path"]
            if path not in self._import_cache:
                self._import_cache[path] = []
            self._import_cache[path].append(dict(r))

        # 載入所有符號定義
        cur.execute("SELECT path, name, qualified_name FROM symbols")
        for r in cur.fetchall():
            name = r["name"]
            if name not in self._symbol_cache:
                self._symbol_cache[name] = []
            self._symbol_cache[name].append(dict(r))

    def resolve(self, caller_path: str, callee_name: str) -> Tuple[Optional[str], Optional[str]]:
        file_imports = self._import_cache.get(caller_path, [])

        # 1. 檢查 from module import callee_name (或別名)
        for imp in file_imports:
            # 檢查是否直接匯入了該函式名稱,或是以別名匯入
            target_name = imp["imported_name"]
            effective_name = imp["alias"] if imp["alias"] else target_name

            if effective_name == callee_name and target_name:
                mod = imp["module_name"]
                # 到 symbol 快取中尋找屬於該模組路徑的定義
                candidates = self._symbol_cache.get(target_name, [])
                for c in candidates:
                    # 比對檔案路徑是否包含該模組名稱特徵
                    if mod.replace(".", "/") in c["path"] or c["path"].endswith(f"{mod}.py"):
                        return c["path"], c["qualified_name"]

        # 2. 檢查屬性呼叫:如 "rc.build_index" 或 "rag_common.build_index"
        if "." in callee_name:
            mod_prefix, func_name = callee_name.split(".", 1)
            for imp in file_imports:
                # 檢查是否有 import module as prefix
                alias_match = imp["alias"] == mod_prefix if imp["alias"] else False
                name_match = imp["module_name"] == mod_prefix or imp["module_name"].endswith(f".{mod_prefix}")
                
                if alias_match or name_match:
                    candidates = self._symbol_cache.get(func_name, [])
                    for c in candidates:
                        if mod_prefix in c["path"]:
                            return c["path"], c["qualified_name"]

        # 3. 退避至全專案唯一性解析
        candidates = self._symbol_cache.get(callee_name, [])
        if len(candidates) == 1:
            return candidates[0]["path"], candidates[0]["qualified_name"]

        return None, None

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

驗證即使存在多個同名函式,Import 上下文也能精準將呼叫導向正確檔案:

# tests/unit/test_import_aware_resolver.py
import sqlite3
from app.codebase import SymbolIndexer
from app.call_graph import ImportAwareResolver
from dataclasses import dataclass

@dataclass
class DummySymbol:
    path: str
    kind: str
    name: str
    qualified_name: str
    start_line: int
    end_line: int
    docstring: str = ""

@dataclass
class DummyImport:
    from_path: str
    module_name: str
    imported_name: str | None
    alias: str | None
    line: int

def test_resolve_with_import_context():
    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),
    ]

    # main.py 明確宣告了 from service import load
    imports = [
        DummyImport("src/main.py", "service", "load", None, 1)
    ]

    indexer.rebuild(symbols=symbols, imports=imports)

    resolver = ImportAwareResolver(conn)

    # 呼叫 load 時,應透過 import 關係準確解析為 src/service.py
    target_path, target_sym = resolver.resolve(caller_path="src/main.py", callee_name="load")
    
    assert target_path == "src/service.py"
    assert target_sym == "load"

執行測試確認綠燈:

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

tests/unit/test_import_aware_resolver.py::test_resolve_with_import_context PASSED [100%]
============================== 1 passed in 0.04s ==============================

實際在 mobileai-local-rag 批次重新解析

將具備 Import 感知的解析器套用到專案中,再次執行呼叫邊連結:

uv run python -m app.cli resolve-calls --import-aware

終端機輸出對比:

{
  "total_call_edges": 42,
  "previously_resolved": 29,
  "import_aware_resolved": 37,
  "unresolved_builtins": 5,
  "status": "ready"
}

引入 Import 關係後,成功將解析率由 69% 提升至 88%(37 條解析成功)。剩下的 5 筆皆為純粹的 Python 原生內建函式(如 print、isinstance),呼叫圖完全消除了模組間的同名歧義。

總結與下一步

今天我們完成了跨檔案關係連結的關鍵突破:

  1. 語境消除歧義:透過 ImportFrom 與別名,在靜態分析下精準還原函式呼叫的真實歸屬。

  2. 圖的品質提升:37 條確定性的跨模組呼叫邊已完整就緒,每一條都有起訖點與行號依據。

有了這張精準的呼叫圖,明天在 Day 25 中,我們將實作 反向影響傳播(Impact Propagation):給定一個被修改的函式,沿著呼叫圖一路逆向往上爬,找出所有直接與間接受影響的 Caller 清單!


上一篇
Day 23:把短名稱解析成跨檔案符號
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言