iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

昨天我們透過 Import-Aware 解析器,在目標專案 mobileai-local-rag 建立了 37 條具備確定性檔案與符號對應的呼叫邊(Call Edges)。

有了這張圖,今天我們直接實作軟體維護中最核心的能力:

「如果我改動了目標函式 $F$,到底有誰會被波及?」

為什麼必須逆向傳播(Reverse Traversal)?

在呼叫圖中,邊的方向是 $A \to B$(代表 $A$ 呼叫了 $B$)。

但當 $B$ 發生變更或產生 Bug 時,影響的傳播方向是反過來的:$B$ 影響 $A$。

如果 $C$ 又呼叫了 $A$($C \to A \to B$),那麼改動 $B$ 時:

  • 直接影響(Direct Impact,Depth 1):$A$。
  • 間接傳遞影響(Transitive Impact,Depth 2):$C$。

因此,影響分析演算法本質上是:以被修改的目標節點為起點,沿著「反向呼叫邊(In-Edges)」進行廣度優先搜尋(BFS)或深度優先搜尋(DFS)。

防禦循環依賴:有界搜尋與拜訪集合

在實際專案中,遞迴呼叫或模組間的相互參照極為常見($A \to B \to A$)。

如果無腦遍歷,很容易陷入無窮迴圈。

影響傳播演算法必須具備兩項防護:

  1. visited 集合:記錄已經排查過的節點 (path, symbol),遇過即跳過。
  2. max_depth 深度上限:預設限制追蹤層數(例如最多 3 層),避免因深層全域工具函式引發影響範圍爆炸。

實作:app/impact_analyzer.py

在 app/impact_analyzer.py 中實作 BFS 影響傳播分析器:

# app/impact_analyzer.py
import sqlite3
from typing import List, Dict, Any, Set, Tuple
from collections import deque
from dataclasses import dataclass

@dataclass
class ImpactNode:
    path: str
    symbol: str
    depth: int
    trigger_line: int
    caller_snippet: str

class ImpactAnalyzer:
    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn

    def trace_impact(self, target_path: str, target_symbol: str, max_depth: int = 3) -> List[ImpactNode]:
        """
        以 (target_path, target_symbol) 為起點,
        沿著反向呼叫邊追蹤所有直接與間接受波及的 Caller。
        """
        impact_nodes: List[ImpactNode] = []
        visited: Set[Tuple[str, str]] = {(target_path, target_symbol)}

        # 佇列元素格式: (current_path, current_symbol, current_depth)
        queue = deque([(target_path, target_symbol, 0)])

        while queue:
            curr_path, curr_sym, depth = queue.popleft()

            if depth >= max_depth:
                continue

            # 逆向找出所有呼叫了 (curr_path, curr_sym) 的 callers
            direct_callers = self._find_inbound_calls(curr_path, curr_sym)

            for caller in direct_callers:
                node_key = (caller["caller_path"], caller["caller_symbol"])
                
                # 記錄此節點
                impact_nodes.append(ImpactNode(
                    path=caller["caller_path"],
                    symbol=caller["caller_symbol"],
                    depth=depth + 1,
                    trigger_line=caller["line"],
                    caller_snippet=f"Calls {curr_sym}() at line {caller['line']}"
                ))

                # 防止環狀依賴與重複拜訪
                if node_key not in visited:
                    visited.add(node_key)
                    queue.append((caller["caller_path"], caller["caller_symbol"], depth + 1))

        return impact_nodes

    def _find_inbound_calls(self, target_path: str, target_symbol: str) -> List[Dict[str, Any]]:
        """從 calls 資料表查詢反向邊"""
        cur = self.conn.cursor()
        cur.execute("""
            SELECT caller_path, caller_symbol, line
            FROM calls
            WHERE (callee_path = ? AND callee_symbol = ?)
               OR (callee_name = ? AND callee_path IS NULL)
            ORDER BY caller_path, line
        """, (target_path, target_symbol, target_symbol))
        return [dict(row) for row in cur.fetchall()]

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

在 tests/unit/test_impact_analyzer.py 中測試「多層傳播」與「循環依賴防護」:

# tests/unit/test_impact_analyzer.py
import sqlite3
from app.call_graph import CallGraphStore, CallEdge
from app.impact_analyzer import ImpactAnalyzer

def test_transitive_impact_propagation():
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    store = CallGraphStore(conn)

    # 模擬鏈式調用:main (L10) -> run_pipeline (L25) -> build_index (L40)
    edges = [
        CallEdge(
            caller_path="src/service.py", caller_symbol="run_pipeline",
            callee_name="build_index", callee_path="src/index.py", callee_symbol="build_index",
            line=25
        ),
        CallEdge(
            caller_path="src/cli.py", caller_symbol="main",
            callee_name="run_pipeline", callee_path="src/service.py", callee_symbol="run_pipeline",
            line=10
        )
    ]
    store.insert_edges(edges)

    analyzer = ImpactAnalyzer(conn)
    impacted = analyzer.trace_impact("src/index.py", "build_index", max_depth=3)

    assert len(impacted) == 2
    # 深度 1:直接呼叫者
    assert impacted[0].symbol == "run_pipeline"
    assert impacted[0].depth == 1
    assert impacted[0].trigger_line == 25

    # 深度 2:間接呼叫者
    assert impacted[1].symbol == "main"
    assert impacted[1].depth == 2
    assert impacted[1].trigger_line == 10

def test_cycle_dependency_guard():
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    store = CallGraphStore(conn)

    # 模擬循環呼叫:A -> B -> A
    edges = [
        CallEdge(
            caller_path="src/a.py", caller_symbol="func_a",
            callee_name="func_b", callee_path="src/b.py", callee_symbol="func_b",
            line=5
        ),
        CallEdge(
            caller_path="src/b.py", caller_symbol="func_b",
            callee_name="func_a", callee_path="src/a.py", callee_symbol="func_a",
            line=8
        )
    ]
    store.insert_edges(edges)

    analyzer = ImpactAnalyzer(conn)
    # 追蹤 func_a 的影響,不能陷入無窮迴圈
    impacted = analyzer.trace_impact("src/a.py", "func_a", max_depth=5)

    assert len(impacted) == 1
    assert impacted[0].symbol == "func_b"

執行測試驗證綠燈:

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

tests/unit/test_impact_analyzer.py::test_transitive_impact_propagation PASSED [ 50%]
tests/unit/test_impact_analyzer.py::test_cycle_dependency_guard PASSED        [100%]
============================== 2 passed in 0.04s ==============================

實際在 mobileai-local-rag 追蹤受波及範圍

以目標專案的核心函式 get_collection_name 為起點,查詢若變更此函式,專案中會影響哪些呼叫端:

uv run python -m app.cli trace-impact --path src/rag_common.py --symbol get_collection_name

終端機輸出層級分明的影響清單:

==================================================
   變更影響分析 (Impact Propagation Report)
   目標節點: src/rag_common.py::get_collection_name
==================================================
[Depth 1 - 直接影響]
  • src/build_index.py::build_index (Line 18)
    └─ Calls get_collection_name() at line 18
  • src/rag_chat.py::init_session (Line 32)
    └─ Calls get_collection_name() at line 32

[Depth 2 - 間接影響]
  • src/rag_chat.py::chat_loop (Line 75)
    └─ Calls init_session() at line 75

總計受波及節點數: 3
==================================================

下一步

今天我們完成了「沿著呼叫圖逆向追蹤受波及函式」的演算法。

明天我們將整合真實開發工作流程,引入 Git Diff 變更分析:自動讀取本機 Git 暫存或 commit 改動了哪些檔案與行號,自動映射為變更符號,並直接輸出完整的終端機影響評估報告!


上一篇
Day 24:打造 Import-Aware 呼叫圖
下一篇
Day 26:連結真實工作流:Git Diff 變更解析與自動衝擊評估
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 26 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言