前面幾章的重構案例都在解決局部問題:提取常數、分離配置、消除重複。本章探討一個更大的挑戰:當系統中有 44 個獨立腳本,各自發展出不同的基礎設施實作時,如何系統性地統一它們?

這是 W22-W24 開發週期中實際執行的三階段統一化重構。每個階段解決一個維度的分歧,最終讓所有 Hook 共享同一套基礎設施。

問題全貌

44 個 Hook,N 種實作

Hook 系統經過數個版本的有機成長,累積了大量不一致:

 1# Hook A:用 common_functions 的 setup_hook_logging
 2from lib.common_functions import setup_hook_logging
 3logger = setup_hook_logging("hook-a")
 4
 5# Hook B:用 hook_logging 的 setup_hook_logging(不同模組,同名函式)
 6from lib.hook_logging import setup_hook_logging
 7logger = setup_hook_logging("hook-b")
 8
 9# Hook C:直接用 logging 模組
10import logging
11logging.basicConfig(level=logging.INFO)
12logger = logging.getLogger(__name__)
13
14# Hook D:print 大法
15def log(msg):
16    print(f"[hook-d] {msg}")

不只日誌,訊息和錯誤處理也是如此:

維度分歧數量常見變體
日誌初始化3 種common_functions / hook_logging / 直接 logging
錯誤處理3 種try-except 包 main / 不處理 / 自訂裝飾器
使用者訊息19 個檔案各自定義每個 Hook 硬編碼自己的字串(共 57+ 個)
logger 作用域2 種模組級全域 / main() 內區域

為什麼要統一

分歧帶來的實際問題:

  1. 修改成本倍增:改一個日誌格式,要改 44 個檔案
  2. 行為不一致:有的 Hook 失敗時靜默,有的會 crash
  3. 難以排查問題:每個 Hook 的日誌格式不同,無法統一搜尋
  4. 新 Hook 沒有範本:寫新 Hook 時不知道該參考哪個

統一化模式

三階段統一化遵循一個共同的模式:

11. 建立統一介面    → 寫一個所有人都要用的模組
22. 漸進式遷移      → 逐批將現有 Hook 切換到新介面
33. 驗證           → 確認行為一致
44. 處理例外       → 處理少數無法直接遷移的情況

這個模式的關鍵在於不一次改完。每個階段只統一一個維度,確認穩定後再進入下一個。

第一階段:統一日誌(W22)

設計統一介面

目標是用一個模組取代三套日誌實作。核心 API 只有兩個函式:

 1# hook_utils.py — 統一日誌模組
 2
 3def setup_hook_logging(hook_name: str) -> logging.Logger:
 4    """建立並設定 Hook 日誌系統
 5
 6    - 建立日誌目錄 .claude/hook-logs/{hook_name}/
 7    - 建立帶時間戳的日誌檔案
 8    - 配置 FileHandler + StreamHandler
 9    """
10
11def run_hook_safely(main_func: Callable[[], int], hook_name: str) -> int:
12    """安全執行 Hook 函式,頂層例外處理
13
14    - 呼叫 setup_hook_logging 取得 logger
15    - 執行 main_func,捕獲所有 Exception
16    - 異常時記錄完整 traceback,返回 1
17    """

setup_hook_logging 封裝了所有日誌配置細節:

 1def setup_hook_logging(hook_name: str) -> logging.Logger:
 2    sanitized_name = _sanitize_hook_name(hook_name)
 3    root_dir = _find_project_root()
 4    log_base_dir = root_dir / ".claude" / "hook-logs" / sanitized_name
 5
 6    try:
 7        log_base_dir.mkdir(parents=True, exist_ok=True)
 8    except OSError:
 9        return _create_fallback_logger(hook_name)
10
11    logger = logging.getLogger(hook_name)
12    _clear_logger_handlers(logger)
13    logger.setLevel(logging.DEBUG)
14
15    is_debug = os.getenv("HOOK_DEBUG", "").lower() == "true"
16    _setup_logger_handlers(logger, log_base_dir, sanitized_name, is_debug)
17
18    return logger

幾個設計決策值得注意:

決策理由
_sanitize_hook_nameHook 名稱可能包含 / 等特殊字元,不能直接用作目錄名
_clear_logger_handlers避免重複呼叫時 handler 累加
Fallback logger目錄建立失敗時仍可輸出到 stdout,不會 crash
HOOK_DEBUG 環境變數開發時可開啟 DEBUG 級別的 stream 輸出

run_hook_safely:一行搞定錯誤處理

這是統一化的核心武器。原本每個 Hook 自己寫 try-except:

1# 重構前:每個 Hook 自己處理
2if __name__ == "__main__":
3    try:
4        result = main()
5        sys.exit(result)
6    except Exception as e:
7        # 有的寫日誌,有的 print,有的什麼都不做
8        print(f"Error: {e}")
9        sys.exit(1)

統一後:

1# 重構後:一行搞定
2if __name__ == "__main__":
3    sys.exit(run_hook_safely(main, "acceptance-gate"))

run_hook_safely 內部處理三個邊界:

  • 返回值驗證main() 可能回傳 None 或布林值,run_hook_safely 會將非整數返回值轉換為 0(成功)或 1(失敗),確保 sys.exit 收到合法的退出碼
  • 不攔截 SystemExit:刻意的 sys.exit() 呼叫不該被吃掉
  • 不攔截 KeyboardInterrupt:Ctrl+C 中斷不該被捕獲

所有其他 Exception 子類別都被捕獲、記錄到日誌、返回錯誤碼 1。

遷移策略

不可能一次改完 44 個檔案。按風險分批:

批次範圍策略
第 1 批5 個低風險 Hook驗證新模組行為正確
第 2 批15 個中等複雜度建立遷移信心
第 3 批剩餘所有 Hook批量遷移

每批遷移後執行全量測試,確認無迴歸。

第二階段:統一訊息(W23)

問題:硬編碼訊息散落各處

日誌統一後,下一個問題浮現:每個 Hook 的使用者訊息各自定義。

1# command-entrance-gate-hook.py
2print("錯誤:未找到待處理的 Ticket\n建議操作: 執行 /ticket create")
3
4# acceptance-gate-hook.py
5print("[ERROR] 子任務未全部完成\nTicket: {}\n請先完成所有子任務")
6
7# main-thread-edit-restriction-hook.py
8print("編輯操作受限")

同樣的問題:改一個訊息格式要翻遍所有 Hook。訊息重複時會出現不一致。

集中管理:hook_messages.py

建立一個訊息常數模組,按職責分類:

 1# lib/hook_messages.py
 2
 3class CoreMessages:
 4    """所有 Hook 共用的通用訊息"""
 5    HOOK_START = "{hook_name} 啟動"
 6    INPUT_EMPTY = "輸入為空,預設允許"
 7    JSON_PARSE_ERROR = "JSON 解析錯誤,預設允許: {error}"
 8
 9class GateMessages:
10    """5 個 Gate Hook 的阻擋/警告訊息"""
11    TICKET_NOT_FOUND_ERROR = """錯誤:未找到待處理的 Ticket
12
13    為什麼阻止執行:
14      開發命令必須有對應的 Ticket,確保工作可追蹤和驗收。
15
16    建議操作:
17      1. 執行 /ticket create 建立新 Ticket
18      2. 或執行 /ticket track claim {id} 認領現有 Ticket"""
19
20class WorkflowMessages:
21    """工作流指導 Hook 的訊息"""
22    EXTERNAL_QUERY_DETECTED = "檢測到 {tool_name} 調用"
23
24class QualityMessages:
25    """品質檢查 Hook 的訊息"""
26    # ...

分類原則

類別包含的 Hook訊息特徵
CoreMessages所有 Hook啟動、錯誤、預設行為
GateMessages5 個 Gate Hook阻擋、警告、建議操作
WorkflowMessages5 個工作流 Hook流程指導、步驟說明
QualityMessages品質檢查 Hook掃描結果、改善建議
ValidationMessages驗證 Hook格式檢查、合規結果

使用方式:

1# 重構前
2print("錯誤:未找到待處理的 Ticket\n建議操作: ...")
3
4# 重構後
5from lib.hook_messages import GateMessages
6print(GateMessages.TICKET_NOT_FOUND_ERROR)

參數化的訊息用 format()

1# 帶參數的訊息
2print(GateMessages.TICKET_NOT_CLAIMED_ERROR.format(ticket_id="0.31.0-W2-001"))

效果

指標重構前重構後
訊息定義位置散落 19 個檔案(57+ 個硬編碼字串)集中 1 個模組(45 個常數)
修改訊息格式逐檔搜尋修改改一處生效
訊息一致性同概念 2-3 種措辭每個概念一個定義
新 Hook 訊息自行發明複用現有類別

第三階段:統一風格(W24)

問題:logger 初始化位置不一致

日誌模組和訊息常數統一後,16 個 Hook 的 logger 初始化位置仍然不一致:

 1# 風格 A:模組級初始化(13 個 Hook)
 2logger = setup_hook_logging("my-hook")  # 最外層
 3
 4def helper():
 5    logger.info("working...")           # 引用全域 logger
 6
 7def main():
 8    helper()
 9    return 0
10
11# 風格 B:main() 內初始化(3 個 Hook)
12def helper(logger):
13    logger.info("working...")           # 接收 logger 參數
14
15def main():
16    logger = setup_hook_logging("my-hook")
17    helper(logger)
18    return 0

目標是統一為風格 B。理由是:模組級初始化的 logger 會在 import 時立即建立日誌目錄和檔案,即使這個模組只是被其他工具引用而不是作為 Hook 執行。將 logger 移入 main() 可以確保只有真正執行時才初始化日誌系統。

事故:7 個 Hook 靜默失敗

統一風格的過程中發生了一個典型的作用域迴歸 bug。把 logger 從模組級移到 main() 內部後,引用全域 logger 的 helper 函式觸發了 NameError

1# 修改後(有 bug)
2def check_acceptance_criteria(ticket_path):
3    logger.info(f"Checking {ticket_path}")  # NameError!
4
5def main():
6    logger = setup_hook_logging("acceptance-gate-hook")
7    result = check_acceptance_criteria(path)

更危險的是,run_hook_safely 的頂層 try-except 捕獲了 NameError(它是 Exception 的子類別),寫入日誌檔案,返回錯誤碼。用戶完全看不到任何異常。7 個 Hook 在至少 2 個 session 中靜默失敗

這個事故的完整分析見下一章:重構陷阱與防護

修正:逐一分析影響範圍

正確的做法是在修改作用域之前,用 AST 分析或 grep 找出所有引用 logger 的非 main 函式,然後為每個函式加入 logger 參數:

1def check_acceptance_criteria(ticket_path, logger):  # 加入參數
2    logger.info(f"Checking {ticket_path}")
3
4def main():
5    logger = setup_hook_logging("acceptance-gate-hook")
6    result = check_acceptance_criteria(path, logger)  # 傳遞 logger

修正規模:7 個 Hook、41 個函式、+143/-81 行。

事故後的改善

這次事故直接促成了 _log_exception 的 stderr 輸出改善(W25-005):在寫入日誌檔案之外,額外輸出一行到 sys.stderr,確保即使 run_hook_safely 捕獲了異常,用戶也能在終端看到 [Hook Error] 提示。

重構後的標準樣板

三階段統一完成後,每個 Hook 的結構變得極為一致:

 1#!/usr/bin/env python3
 2"""Hook 說明文件"""
 3
 4import sys
 5import json
 6from pathlib import Path
 7
 8# 引入統一基礎設施
 9# Hook 不是安裝的套件,需要手動把 hooks/ 目錄加入 Python 搜尋路徑
10# 這樣才能 import 同目錄下的 hook_utils 和 lib/ 子模組
11_hooks_dir = Path(__file__).parent
12if _hooks_dir not in [p for p in sys.path if Path(p) == _hooks_dir]:
13    sys.path.insert(0, str(_hooks_dir))
14
15from hook_utils import run_hook_safely, setup_hook_logging
16from lib.hook_messages import GateMessages, CoreMessages
17
18# 常數定義
19EXIT_SUCCESS = 0
20EXIT_BLOCK = 2
21
22# ---- 業務邏輯 ----
23
24def check_something(data, logger):
25    """每個 helper 都接收 logger 參數"""
26    logger.info(CoreMessages.HOOK_START.format(hook_name="my-hook"))
27    # ...
28
29def main():
30    logger = setup_hook_logging("my-hook")
31    # 讀取輸入、執行檢查、輸出結果
32    return EXIT_SUCCESS
33
34# ---- 入口 ----
35if __name__ == "__main__":
36    sys.exit(run_hook_safely(main, "my-hook"))

對比重構前後:

面向重構前重構後
日誌初始化3 種模組 + 散裝 loggingsetup_hook_logging 一行
錯誤處理自寫 try-except 或不處理run_hook_safely 一行
使用者訊息硬編碼在各檔案引用 hook_messages 常數
logger 傳遞全域變數參數傳遞
入口點5-15 行樣板1 行
新 Hook 開發參考哪個都不確定複製標準樣板

統一化的通用教訓

教訓 1:先建介面,再遷移

不要試圖「就地重構」現有程式碼。先寫好新模組,測試通過,然後逐步切換。

1錯誤路徑:邊改邊用 → 半成品狀態 → 新舊混合更亂
2正確路徑:新模組獨立完成 → 逐批遷移 → 舊模組標記棄用

教訓 2:分批遷移,每批驗證

44 個 Hook 一次改完的風險太高。分批的目的不只是降低風險,更是建立信心。第一批 5 個成功後,第二批 15 個就能更快。

教訓 3:統一風格是最危險的一步

統一「介面」(W22 日誌、W23 訊息)相對安全,因為是新增模組再切換引用。統一「風格」(W24 作用域)涉及修改現有程式碼的結構,牽一髮動全身。

風險等級操作類型範例
新增模組 + 替換 importW22 新增 hook_utils.py
替換訊息字串W23 硬編碼 → 常數引用
修改變數作用域W24 全域 logger → 參數傳遞

教訓 4:安全網要先到位

W24 的事故之所以嚴重,是因為安全網(stderr 輸出)在事故之後才補上。正確的順序應該是:

11. 先確認安全網(stderr 輸出、測試覆蓋)
22. 再執行風險操作(作用域修改)
33. 最後清理(移除棄用程式碼)

量化成果

三階段統一化的最終成果:

指標統一前統一後
日誌模組3 個1 個 (hook_utils.py)
錯誤處理模式3 種1 種 (run_hook_safely)
訊息定義位置19 個檔案(57+ 個字串)1 個 (hook_messages.py)
logger 初始化風格2 種1 種 (main 內 + 參數傳遞)
新 Hook 開發時間~30 分鐘~10 分鐘
Hook 入口樣板5-15 行1 行

思考題

  1. 如果你的系統有 100 個腳本而不是 44 個,統一化策略會有什麼不同?
  2. run_hook_safely 選擇返回錯誤碼而不是重新拋出異常,這個設計在什麼情境下會是錯誤的?
  3. 訊息常數用 class 分類(GateMessagesWorkflowMessages)而不是單一字典,有什麼優缺點?

實作練習

  1. 為一組 3 個以上的腳本設計統一日誌模組,包含 setup_loggingrun_safely 兩個函式
  2. 掃描一個多檔案專案,找出所有硬編碼的使用者訊息字串,規劃集中管理方案
  3. 嘗試用本章的分批遷移策略,將練習 2 的訊息逐批遷移到常數模組

小結

  • 大規模統一化的核心模式:建立統一介面 -> 分批遷移 -> 驗證 -> 處理例外
  • 統一「介面」(新增模組 + 替換引用)風險低,統一「風格」(修改現有結構)風險高
  • run_hook_safely 一行取代 44 套自寫的錯誤處理,確保行為一致
  • 訊息集中化用 Messages 類別按使用者角色分組,消除散落的硬編碼字串
  • 分批遷移不只降低風險,更是建立信心的過程
  • 安全網(stderr 輸出、測試覆蓋)必須在風險操作之前到位

上一章:配置分離與常數管理 下一章:重構陷阱與防護 相關:5.5 頂層例外處理機制