iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
AI Engineering

30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰系列 第 22 篇

Day 22:從 AST 找出函式呼叫:走訪 ast.Call 節點建立呼叫邊

  • 分享至 

  • xImage
  •  

昨天我們定義了呼叫邊(CallEdge)的資料結構,並在 SQLite 中建置了 calls 資料表。

要建立呼叫關係,第一步不是憑空猜測架構圖,而是回到語法樹,精確找出每個函式內部到底呼叫了誰。

在 Python 的 AST 中,所有的函式調用事件都會表現為一個 ast.Call 節點。今天我們的目標:擴充 AST 解析器,走訪函式本體中的 ast.Call,精準擷取「Caller 符號」、「被呼叫的名稱(Callee Name)」以及「發生的行號」。

ast.Call 節點長什麼樣?

Python 的呼叫表達式主要有兩種常見形態:

  1. 直接呼叫(識別符號呼叫):
build_index()

  • AST 節點:node.func 是一個 ast.Name 節點。
  • 提取名稱:直接取 node.func.id(即 "build_index")。
  1. 屬性方法呼叫(物件方法或模組呼叫):
client.search(...)
self.validate()

  • AST 節點:node.func 是一個 ast.Attribute 節點。
  • 提取名稱:取其屬性名稱 node.func.attr(如 "search"、"validate"),或是遞迴展開為 "client.search"。

為了在第一版保持解析穩定,我們先提取可識別的最底層名稱(或屬性鍊),並保留其呼叫所在的真實行號。

核心實作:CallVisitor 走訪器

在 app/call_graph.py 中實作專門萃取呼叫事件的 AST Visitor:

# app/call_graph.py (擴充 AST 呼叫萃取邏輯)
import ast
from pathlib import Path
from typing import List, Optional
from app.call_graph import CallEdge

class CallVisitor(ast.NodeVisitor):
    def __init__(self, rel_path: str):
        self.rel_path = rel_path
        self.scope_stack: List[str] = []
        self.edges: List[CallEdge] = []

    def visit_ClassDef(self, node: ast.ClassDef):
        # 追蹤當前類別範圍
        self.scope_stack.append(node.name)
        self.generic_visit(node)
        self.scope_stack.pop()

    def visit_FunctionDef(self, node: ast.FunctionDef):
        self._handle_func(node)

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
        self._handle_func(node)

    def _handle_func(self, node):
        func_name = ".".join(self.scope_stack + [node.name])
        self.scope_stack.append(node.name)

        # 走訪函式內部所有子節點
        self.generic_visit(node)

        self.scope_stack.pop()

    def visit_Call(self, node: ast.Call):
        # 只有在函式內部發生的呼叫才記錄 caller;頂層全域呼叫記為 <module>
        current_caller = ".".join(self.scope_stack) if self.scope_stack else "<module>"
        callee_name = self._resolve_callee_name(node.func)

        if callee_name:
            self.edges.append(CallEdge(
                caller_path=self.rel_path,
                caller_symbol=current_caller,
                callee_name=callee_name,
                line=node.lineno
            ))

        # 繼續走訪參數內部可能存在的巢狀呼叫 (如 foo(bar()))
        self.generic_visit(node)

    def _resolve_callee_name(self, node: ast.AST) -> Optional[str]:
        """解析呼叫目標名稱,支援 Name 與 Attribute 節點"""
        if isinstance(node, ast.Name):
            return node.id
        elif isinstance(node, ast.Attribute):
            # 取屬性名稱,例如 client.search -> "search" (或保存完整屬性)
            value_name = self._resolve_callee_name(node.value)
            if value_name:
                return f"{value_name}.{node.attr}"
            return node.attr
        return None


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

在 tests/unit/test_call_visitor.py 驗證函式呼叫的擷取與行號精確度:

# tests/unit/test_call_visitor.py
import ast
from app.call_graph import CallVisitor

def test_extract_calls_from_function_and_methods():
    source_code = (
        "def setup():\n"
        "    init_logger()\n"           # 第 2 行呼叫 init_logger
        "\n"
        "class Pipeline:\n"
        "    def run(self):\n"
        "        data = self.fetch()\n"  # 第 6 行呼叫 self.fetch
        "        process(data)\n"        # 第 7 行呼叫 process
    )

    tree = ast.parse(source_code)
    visitor = CallVisitor(rel_path="src/pipeline.py")
    visitor.visit(tree)

    edges = visitor.edges

    # 驗證總共抓到 3 次呼叫
    assert len(edges) == 3

    # 1. 頂層函式呼叫
    assert edges[0].caller_path == "src/pipeline.py"
    assert edges[0].caller_symbol == "setup"
    assert edges[0].callee_name == "init_logger"
    assert edges[0].line == 2

    # 2. 類別成員方法呼叫
    assert edges[1].caller_symbol == "Pipeline.run"
    assert edges[1].callee_name == "self.fetch"
    assert edges[1].line == 6

    assert edges[2].caller_symbol == "Pipeline.run"
    assert edges[2].callee_name == "process"
    assert edges[2].line == 7

執行測試確認通過:

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

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

實際掃描 mobileai-local-rag 呼叫事件

將 CallVisitor 整合進專案索引流程,掃描目標專案全部 7 支業務檔案:

uv run python -m app.cli index-calls

終端機輸出報告:

{
  "scanned_files": 7,
  "extracted_call_edges": 42,
  "status": "ready"
}

在目標專案中,語法樹一共抓出了 42 處具體的函式調用事件,每一筆都標記著確切的 caller、callee 與行號。

總結與下一步

今天我們完成了「看見程式碼動態關係」的第一步:

  1. 無死角捕捉:透過 ast.Call 將一般函式呼叫與物件方法調用全部轉換成 CallEdge。
  2. 座標精確:保留行號,確保未來追蹤影響時每一條呼叫鏈都有據可查。

但問題來了:現在 AST 看到的被呼叫端很多只是簡短名稱(例如只看到呼叫了 build_index 或 load)。如果不同檔案有多個同名函式,它到底呼叫了哪一個?

明天在 Day 23 中,我們將實作 符號解析(Symbol Resolution),結合 Day 10 的 Symbol 索引,把簡短的呼叫名稱解析成具體的跨檔案唯一符號!


上一篇
Day 21:從變更分析開始:定義呼叫邊(Call Edge)與資料模型
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 22 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言