昨天(Day 10)我們把 AST 提取出的結構持久化到了 SQLite,並保留了同名 Symbol 的真實存在。
今天我們要利用 imports 資料表,回答接手陌生專案時最關鍵的關係問題:「如果我修改了某個模組,到底有誰直接 import 了它?」
在程式碼分析裡,畫出「完整的全域呼叫圖(Call Graph)」極其困難,尤其 Python 充斥著動態 import、字串反射(getattr)與工廠模式。
如果第一版就宣稱能捕捉所有依賴,那是自欺欺人。因此我們採取保守但可靠的策略:只記錄靜態程式碼中看得到的「直接 Import 關係」,並將查詢結果定位為「優先排查的候選範圍(Candidate Scope)」。
find_importers() 實作我們在 app/codebase.py 的 SymbolIndexer 中加入逆向依賴查詢方法:
# app/codebase.py (擴充依賴查詢功能)
from typing import List, Dict, Any
class SymbolIndexer:
# ... (維持昨日的 __init__ 與 _init_db) ...
def find_importers(self, module_name: str) -> List[Dict[str, Any]]:
"""
找出專案中所有直接 import 指定模組的檔案路徑與行號。
支援模組全名比對與子模組引用比對。
"""
cur = self.conn.cursor()
# 查詢精確符合 module_name,或是以該模組開頭的引用
cur.execute("""
SELECT from_path, module_name, imported_name, alias, line
FROM imports
WHERE module_name = ?
OR module_name LIKE ?
ORDER BY from_path, line
""", (module_name, f"{module_name}.%"))
rows = cur.fetchall()
results = []
for row in rows:
results.append({
"path": row["from_path"],
"module_name": row["module_name"],
"imported_name": row["imported_name"],
"alias": row["alias"],
"line": row["line"]
})
return results
app/tools.py將查詢結果封裝成 Day 8 約定好的標準 Evidence 契約結構,讓 CLI 與 LLM 都能使用相同的資料格式:
# app/tools.py (擴充 tools 介面)
from typing import List, Dict, Any
from app.codebase import SymbolIndexer
class CodebaseTools:
def __init__(self, indexer: SymbolIndexer):
self.indexer = indexer
def find_importers(self, module_name: str) -> List[Dict[str, Any]]:
"""給 CLI 與 Agent 呼叫的唯讀依賴查詢工具"""
records = self.indexer.find_importers(module_name)
evidences = []
for r in records:
# 組合出標準 Evidence 格式
alias_note = f" (as {r['alias']})" if r["alias"] else ""
name_note = f"::{r['imported_name']}" if r["imported_name"] else ""
evidences.append({
"path": r["path"],
"start_line": r["line"],
"end_line": r["line"],
"kind": "import",
"name": f"{r['module_name']}{name_note}{alias_note}",
"snippet": f"import {r['module_name']}"
})
return evidences
tests/unit/test_importers.py在 tests/unit/test_importers.py 驗證模組被不同檔案引用時的檢索行為:
# tests/unit/test_importers.py
from app.codebase import SymbolIndexer
from app.tools import CodebaseTools
from dataclasses import dataclass
@dataclass
class DummyImport:
from_path: str
module_name: str
imported_name: str | None
alias: str | None
line: int
def test_find_importers_direct_and_submodules(tmp_path):
db_file = tmp_path / "test.db"
indexer = SymbolIndexer(db_path=str(db_file))
# 模擬四個檔案 import 了 rag_common 模組
dummy_imports = [
DummyImport("src/build_index.py", "rag_common", None, None, 4),
DummyImport("src/chat_reranker.py", "rag_common", "rerank", None, 6),
DummyImport("src/chat_reranker_guarded.py", "rag_common", None, "rc", 6),
DummyImport("src/rag_chat.py", "rag_common", "chat", None, 10),
]
indexer.rebuild(symbols=[], imports=dummy_imports)
tools = CodebaseTools(indexer)
results = tools.find_importers("rag_common")
assert len(results) == 4
paths = [r["path"] for r in results]
assert "src/build_index.py" in paths
assert "src/chat_reranker.py" in paths
assert "src/chat_reranker_guarded.py" in paths
assert "src/rag_chat.py" in paths
assert results[0]["start_line"] == 4
執行測試確認綠燈:
uv run pytest tests/unit/test_importers.py -v
tests/unit/test_importers.py::test_find_importers_direct_and_submodules PASSED [100%]
============================== 1 passed in 0.04s ==============================
mobileai-local-rag在 app/cli.py 加上 importers 子指令後,我們在終端機直接測試:
uv run python -m app.cli importers rag_common
輸出結果精準命中了專案中 4 個直接引用的檔案與行號:
src/build_index.py:4
src/chat_reranker.py:6
src/chat_reranker_guarded.py:6
src/rag_chat.py:10
此輸出不僅可以在終端機供開發者點擊跳轉,也是當天 Day 8 提供給 Ollama 整理回覆時所依賴的確定性依據。
必須在工具輸出與心態上時刻提醒自己一件事:
A import B,只代表 A 的檔案層級依賴 B,不代表 A 的所有函式都在執行期呼叫了 B。
因此,系統給出的結論應誠實表述為:
「下列檔案直接 import 了此模組,為變更發生時的第一層候選影響範圍。」
這不是功能的缺陷,而是嚴謹工程工具應有的誠實防線。
到今天為止,我們已經完成了三件靜態分析核心能力:
Symbol 定義檢索:知道函式與常數在哪裡定義(Day 10)。
Import 關係追蹤:知道模組被誰引用(Day 11)。
安全行號證據:所有結果皆具備真實相對路徑與行號(Day 4-8)。
但目前所有查詢都建立在「使用者知道精準名稱(如 rag_common 或 COLLECTION_NAME)」的前提下。如果使用者問的是模糊概念(例如「向量資料庫本機存在哪裡?」),字串比對就會漏掉。
明天在 Day 12 中,我們將引進本地 Ollama 的 bge-m3:latest 模型,把程式碼區塊轉換為 1024 維向量,正式為 Codebase 建立語意檢索索引!