昨天我們訂下了「寧缺毋濫、錯的圖比不完整的圖更危險」的原則,對同名符號(例如多個檔案都有 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 層:直接 ImportFrom 解析(精準鎖定)
檢查該檔案是否有 from <module> import <callee_name> 或別名宣告。若有,直接鎖定目標模組,即使專案其他地方有 10 個同名函式也能唯一解析。
第 2 層:模組屬性呼叫(Module Attribute Call)
若呼叫形式為 service.load(),檢查該檔案是否有 import service。若有,直接將目標解析為 service 模組下的 load。
第 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),呼叫圖完全消除了模組間的同名歧義。
今天我們完成了跨檔案關係連結的關鍵突破:
語境消除歧義:透過 ImportFrom 與別名,在靜態分析下精準還原函式呼叫的真實歸屬。
圖的品質提升:37 條確定性的跨模組呼叫邊已完整就緒,每一條都有起訖點與行號依據。
有了這張精準的呼叫圖,明天在 Day 25 中,我們將實作 反向影響傳播(Impact Propagation):給定一個被修改的函式,沿著呼叫圖一路逆向往上爬,找出所有直接與間接受影響的 Caller 清單!