iT邦幫忙

2026 iThome 鐵人賽

DAY 27
0

在 Day 25 與 Day 26 中,我們完成了從 Git Diff 變更行號到下游受波及函式的自動影響分析鏈。

但 Python 是一門極度靈活的動態語言。任何聲稱「100% 靜態分析覆蓋 Python 呼叫圖」的工具,本質上都是不切實際的。

當程式碼出現以下模式時,傳統 AST 靜態解析必然會產生盲區:

  1. 動態反射呼叫(Dynamic Dispatch / Reflection):使用 getattr(module, func_name)() 或 globals()[name]()。
  2. 動態模組載入(Runtime Imports):使用 importlib.import_module() 或在函式內部條件式 __import__()。
  3. 高階函式傳遞(First-Class Functions):將函式本身作為引數傳遞給字典或佇列(例如事件分發器 handlers[event](payload))。

如果系統因為「解析不出來」就假裝這些呼叫不存在,變更分析就會給出危險的假陰性(False Negative,漏報受波及程式碼)。

今天我們落實最後一道靜態分析防線:檢測潛在的動態特徵,輸出未解析警告(Unresolved Warnings),並為影響分析結果標記置信度(Confidence Score)。

置信度模型(Confidence Grading)

我們將影響分析的每一個受波及節點與整體報告分為三個置信度等級:

置信等級 判定條件 意義與建議
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 來動態選擇防護規則。系統及時捕捉到該處語法特徵,並將其標記在案。

下一步

今天我們完成了靜態分析工具最關鍵的「誠實機制」:

  1. 不裝作全知:主動偵測 getattr 與動態呼叫,坦承靜態分析的物理邊界。
  2. 標記置信度:讓工程師一眼分辨哪些影響鏈是 100% 確定,哪些節點需要額外人工排查。

明天,我們將把這四週累積的所有元件收攏整合:打造 受限唯讀 Agent 的決策迴圈(Guarded ReadOnly Agent Loop),讓 Agent 在 Zero-Trust 的安全沙盒中調用檢索、呼叫圖與影響分析工具,產出兼具證據與行號的最終解答!


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

尚未有邦友留言

立即登入留言