iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

昨天(Day 10)我們把 AST 提取出的結構持久化到了 SQLite,並保留了同名 Symbol 的真實存在。

今天我們要利用 imports 資料表,回答接手陌生專案時最關鍵的關係問題:「如果我修改了某個模組,到底有誰直接 import 了它?」

為什麼第一版只做「直接 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 了此模組,為變更發生時的第一層候選影響範圍。」
這不是功能的缺陷,而是嚴謹工程工具應有的誠實防線。

總結與下一步

到今天為止,我們已經完成了三件靜態分析核心能力:

  1. Symbol 定義檢索:知道函式與常數在哪裡定義(Day 10)。

  2. Import 關係追蹤:知道模組被誰引用(Day 11)。

  3. 安全行號證據:所有結果皆具備真實相對路徑與行號(Day 4-8)。

但目前所有查詢都建立在「使用者知道精準名稱(如 rag_common 或 COLLECTION_NAME)」的前提下。如果使用者問的是模糊概念(例如「向量資料庫本機存在哪裡?」),字串比對就會漏掉。

明天在 Day 12 中,我們將引進本地 Ollama 的 bge-m3:latest 模型,把程式碼區塊轉換為 1024 維向量,正式為 Codebase 建立語意檢索索引!


上一篇
Day 10:把 Symbol 變成可查的索引:SQLite 持久化與同名衝突處理
下一篇
Day 12:把結構化 Chunk 變成向量:本機 Embedding 與 SQLite 向量儲存
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言