在 Day 25 與 Day 26 中,我們完成了從 Git Diff 變更行號到下游受波及函式的自動影響分析鏈。
但 Python 是一門極度靈活的動態語言。任何聲稱「100% 靜態分析覆蓋 Python 呼叫圖」的工具,本質上都是不切實際的。
當程式碼出現以下模式時,傳統 AST 靜態解析必然會產生盲區:
getattr(module, func_name)() 或 globals()[name]()。importlib.import_module() 或在函式內部條件式 __import__()。handlers[event](payload))。如果系統因為「解析不出來」就假裝這些呼叫不存在,變更分析就會給出危險的假陰性(False Negative,漏報受波及程式碼)。
今天我們落實最後一道靜態分析防線:檢測潛在的動態特徵,輸出未解析警告(Unresolved Warnings),並為影響分析結果標記置信度(Confidence Score)。
我們將影響分析的每一個受波及節點與整體報告分為三個置信度等級:
| 置信等級 | 判定條件 | 意義與建議 |
|---|---|---|
HIGH |
呼叫邊具備確定的 Import 關係與唯一符號定義。 | 影響高度確定,列為優先必測清單。 |
MEDIUM |
僅透過全專案唯一符號解析(無明確模組 import 宣告)。 | 高機率命中,但建議人工確認上下文。 |
LOW (WITH_WARNINGS) |
該函式內部包含 getattr、importlib 或無法解析的動態分發。 |
存在隱藏呼叫鏈風險,不能僅依賴呼叫圖。 |
app/dynamic_detector.py在語法走訪過程中,主動偵測常見的動態黑魔法特徵:
# app/dynamic_detector.py
import ast
from dataclasses import dataclass
from typing import List, Dict, Any
@dataclass
class DynamicWarning:
path: str
symbol: str
line: int
pattern_type: str # "getattr" | "importlib" | "dynamic_subscript_call"
snippet: str
class DynamicCallDetector(ast.NodeVisitor):
def __init__(self, rel_path: str):
self.rel_path = rel_path
self.scope_stack: List[str] = []
self.warnings: List[DynamicWarning] = []
def visit_FunctionDef(self, node: ast.FunctionDef):
self.scope_stack.append(node.name)
self.generic_visit(node)
self.scope_stack.pop()
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef):
self.scope_stack.append(node.name)
self.generic_visit(node)
self.scope_stack.pop()
def visit_Call(self, node: ast.Call):
curr_sym = ".".join(self.scope_stack) if self.scope_stack else "<module>"
# 1. 偵測 getattr(...) 呼叫
if isinstance(node.func, ast.Name) and node.func.id == "getattr":
self.warnings.append(DynamicWarning(
path=self.rel_path,
symbol=curr_sym,
line=node.lineno,
pattern_type="getattr",
snippet="Dynamic attribute resolution via getattr()"
))
# 2. 偵測 importlib.import_module(...)
elif isinstance(node.func, ast.Attribute) and node.func.attr == "import_module":
self.warnings.append(DynamicWarning(
path=self.rel_path,
symbol=curr_sym,
line=node.lineno,
pattern_type="importlib",
snippet="Dynamic module loading via importlib.import_module()"
))
# 3. 偵測字典/陣列下標呼叫: router[key](arg)
elif isinstance(node.func, ast.Subscript):
self.warnings.append(DynamicWarning(
path=self.rel_path,
symbol=curr_sym,
line=node.lineno,
pattern_type="dynamic_subscript_call",
snippet="Indirect call via subscript expression (e.g. registry[cmd]())"
))
self.generic_visit(node)
app/impact_analyzer.py將動態特徵與呼叫邊的解析品質結合,算出影響節點的置信度:
# app/impact_analyzer.py (擴充置信度與警告機制)
import sqlite3
from typing import List, Dict, Any, Set, Tuple
from collections import deque
from dataclasses import dataclass
from app.dynamic_detector import DynamicWarning
@dataclass
class ScoredImpactNode:
path: str
symbol: str
depth: int
trigger_line: int
caller_snippet: str
confidence: str # "HIGH" | "MEDIUM" | "LOW"
warnings: List[str]
class RobustImpactAnalyzer:
def __init__(self, conn: sqlite3.Connection, warnings: List[DynamicWarning] = None):
self.conn = conn
self.warnings_map: Dict[Tuple[str, str], List[DynamicWarning]] = {}
for w in (warnings or []):
key = (w.path, w.symbol)
self.warnings_map.setdefault(key, []).append(w)
def trace_impact_with_confidence(self, target_path: str, target_symbol: str, max_depth: int = 3) -> List[ScoredImpactNode]:
impact_nodes: List[ScoredImpactNode] = []
visited: Set[Tuple[str, str]] = {(target_path, target_symbol)}
queue = deque([(target_path, target_symbol, 0)])
while queue:
curr_path, curr_sym, depth = queue.popleft()
if depth >= max_depth:
continue
cur = self.conn.cursor()
cur.execute("""
SELECT caller_path, caller_symbol, line, callee_path
FROM calls
WHERE (callee_path = ? AND callee_symbol = ?)
OR (callee_name = ? AND callee_path IS NULL)
ORDER BY caller_path, line
""", (curr_path, curr_sym, curr_sym))
for row in cur.fetchall():
c_path = row["caller_path"]
c_sym = row["caller_symbol"]
line = row["line"]
is_fully_resolved = bool(row["callee_path"])
# 判斷置信度與蒐集警告
node_warnings = []
warn_records = self.warnings_map.get((c_path, c_sym), [])
for wr in warn_records:
node_warnings.append(f"Line {wr.line}: {wr.snippet}")
if node_warnings:
confidence = "LOW"
elif is_fully_resolved:
confidence = "HIGH"
else:
confidence = "MEDIUM"
impact_nodes.append(ScoredImpactNode(
path=c_path,
symbol=c_sym,
depth=depth + 1,
trigger_line=line,
caller_snippet=f"Calls {curr_sym}() at line {line}",
confidence=confidence,
warnings=node_warnings
))
node_key = (c_path, c_sym)
if node_key not in visited:
visited.add(node_key)
queue.append((c_path, c_sym, depth + 1))
return impact_nodes
tests/unit/test_dynamic_detector.py在 tests/unit/test_dynamic_detector.py 驗證動態特徵擷取與置信度降級:
# tests/unit/test_dynamic_detector.py
import ast
import sqlite3
from app.dynamic_detector import DynamicCallDetector
from app.impact_analyzer import RobustImpactAnalyzer
from app.call_graph import CallGraphStore, CallEdge
def test_detect_getattr_and_importlib():
code = (
"import importlib\n"
"def dispatch(mod_name, func_name):\n"
" mod = importlib.import_module(mod_name)\n" # L3
" target = getattr(mod, func_name)\n" # L4
" return target()\n"
)
tree = ast.parse(code)
detector = DynamicCallDetector("src/dispatcher.py")
detector.visit(tree)
assert len(detector.warnings) == 2
types = {w.pattern_type for w in detector.warnings}
assert "importlib" in types
assert "getattr" in types
def test_impact_confidence_degradation():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
store = CallGraphStore(conn)
# 模擬 caller_dynamic 內部有動態特性
edges = [
CallEdge(
caller_path="src/dispatch.py", caller_symbol="dispatch",
callee_name="run", callee_path="src/task.py", callee_symbol="run",
line=10
)
]
store.insert_edges(edges)
# 建立動態警告紀錄
from app.dynamic_detector import DynamicWarning
warnings = [
DynamicWarning("src/dispatch.py", "dispatch", 8, "getattr", "Dynamic reflection")
]
analyzer = RobustImpactAnalyzer(conn, warnings=warnings)
results = analyzer.trace_impact_with_confidence("src/task.py", "run")
assert len(results) == 1
# 因為帶有 getattr 警告,置信度必須被自動降為 LOW
assert results[0].confidence == "LOW"
assert len(results[0].warnings) == 1
執行測試確認通過:
uv run pytest tests/unit/test_dynamic_detector.py -v
tests/unit/test_dynamic_detector.py::test_detect_getattr_and_importlib PASSED [ 50%]
tests/unit/test_dynamic_detector.py::test_impact_confidence_degradation PASSED [100%]
============================== 2 passed in 0.04s ==============================
mobileai-local-rag 掃描動態警告在 CLI 執行全庫動態特徵掃描:
uv run python -m app.cli scan-dynamic-patterns
輸出結果:
============================================================
Python 動態特性與盲點掃描報告
============================================================
掃描檔案數: 7
偵測到動態特徵: 1 處
[警示節點]
• src/chat_reranker_guarded.py::guarded_rerank (Line 42)
└─ Pattern: getattr -> Dynamic attribute resolution via getattr()
影響評估提示:
若變更涉及以上函式,下游呼叫鏈可能包含未捕捉的反射調用,請輔以整合測試驗證。
============================================================
在實際目標專案中,chat_reranker_guarded.py 使用了 getattr 來動態選擇防護規則。系統及時捕捉到該處語法特徵,並將其標記在案。
今天我們完成了靜態分析工具最關鍵的「誠實機制」:
getattr 與動態呼叫,坦承靜態分析的物理邊界。明天,我們將把這四週累積的所有元件收攏整合:打造 受限唯讀 Agent 的決策迴圈(Guarded ReadOnly Agent Loop),讓 Agent 在 Zero-Trust 的安全沙盒中調用檢索、呼叫圖與影響分析工具,產出兼具證據與行號的最終解答!