昨天我們透過 Import-Aware 解析器,在目標專案 mobileai-local-rag 建立了 37 條具備確定性檔案與符號對應的呼叫邊(Call Edges)。
有了這張圖,今天我們直接實作軟體維護中最核心的能力:
「如果我改動了目標函式 $F$,到底有誰會被波及?」
在呼叫圖中,邊的方向是 $A \to B$(代表 $A$ 呼叫了 $B$)。
但當 $B$ 發生變更或產生 Bug 時,影響的傳播方向是反過來的:$B$ 影響 $A$。
如果 $C$ 又呼叫了 $A$($C \to A \to B$),那麼改動 $B$ 時:
因此,影響分析演算法本質上是:以被修改的目標節點為起點,沿著「反向呼叫邊(In-Edges)」進行廣度優先搜尋(BFS)或深度優先搜尋(DFS)。
在實際專案中,遞迴呼叫或模組間的相互參照極為常見($A \to B \to A$)。
如果無腦遍歷,很容易陷入無窮迴圈。
影響傳播演算法必須具備兩項防護:
visited 集合:記錄已經排查過的節點 (path, symbol),遇過即跳過。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 改動了哪些檔案與行號,自動映射為變更符號,並直接輸出完整的終端機影響評估報告!