診斷 Bug
一套應付硬 bug 的紀律。只有明確證成時才可跳過階段。
探索程式碼時,先讀 CONTEXT.md(如果存在)以取得相關模組的清晰心智模型,並檢查你要觸及的區域中的 ADR。
第一階段——建立回饋迴圈
這就是本技能的核心。 其他一切都是機械性作業。如果你對這個 bug 有一個緊密的通過/失敗訊號——一個會因為_這個_ bug 而變紅的訊號——你就會找到原因;二分、假設檢驗與插樁都只是在消耗它。如果你沒有這樣的訊號,再怎麼瞪著程式碼看也救不了你。
在這裡投入不成比例的精力。要積極。要有創意。拒絕放棄。
建立回饋迴圈的方法——大致依此順序嘗試
- 失敗測試,在任何觸及 bug 的接縫——單元、整合、e2e。
- curl / HTTP 腳本,對著正在執行的開發伺服器。
- CLI 呼叫,搭配固定裝置輸入,把 stdout 跟已知良好快照做 diff。
- 無頭瀏覽器腳本(Playwright / Puppeteer)——驅動 UI,對 DOM / 主控台 / 網路做斷言。
- 重播擷取的追蹤。把真實的網路請求 / payload / 事件日誌存到磁碟;在隔離環境中透過程式碼路徑重播。
- 一次性測試架。啟動系統的最小子集合(一個服務、模擬過的相依),用單一函式呼叫去觸發 bug 的程式碼路徑。
- 屬性 / 模糊測試迴圈。如果 bug 是「輸出偶爾錯誤」,跑 1000 個隨機輸入,找出失敗模式。
- 二分測試架。如果 bug 出現在兩個已知狀態之間(commit、資料集、版本),自動化「在狀態 X 啟動、檢查、重複」,讓你能
git bisect run。 - 差異迴圈。把相同輸入跑過舊版 vs 新版(或兩種設定),diff 輸出。
- HITL bash 腳本。最後手段。如果必須由人點擊,用
scripts/hitl-loop.template.sh驅動_他們_,讓迴圈仍然結構化。擷取的輸出會回饋給你。
建立正確的回饋迴圈,bug 就完成九成了。
收緊迴圈
把迴圈當產品來經營。一旦有了_一個_迴圈,就收緊它:
- 能不能讓它更快?(快取設定、跳過無關的初始化、縮小測試範圍。)
- 能不能讓訊號更銳利?(斷言具體症狀,而不是「沒有崩潰」。)
- 能不能讓它更確定?(釘住時間、播種 RNG、隔離檔案系統、凍結網路。)
一個要花 30 秒的搖擺迴圈只比沒有迴圈好一點點;一個 2 秒且確定的是緊密的——這是除錯的超能力。
非確定性 bug
目標不是乾淨的重現,而是更高的重現率。把觸發器重複 100 次、平行化、加壓力、收窄時間窗、注入 sleep。50% 會搖擺的 bug 可以除錯;1% 的不能——持續拉高重現率,直到它能被除錯。
當你真的無法建立迴圈
停下來,明確說出來。列出你試過什麼。向使用者要求:(a) 存取能重現它的任何環境,(b) 一個擷取的產物(HAR 檔、日誌傾倒、核心傾倒、帶時間戳的螢幕錄影),或 (c) 加入暫時正式環境插樁的權限。不要在沒有迴圈的情況下繼續做假設。
完成標準——一個會變紅的緊密迴圈
第一階段完成的條件是迴圈緊密且能變紅:你能指認出一個指令——一個腳本路徑、一次測試呼叫、一個 curl——你至少已經跑過一次(貼出該呼叫及其輸出),而且它:
- 能變紅——它驅動真正的 bug 程式碼路徑,並斷言使用者的確切症狀,所以它會因為這個 bug 而變紅、修好後變綠。不是「跑起來沒有報錯」——它必須能_抓到這個特定 bug_。
- 確定——每次執行結果相同(搖擺 bug:釘住的高重現率,如上述)。
- 快速——幾秒,不是幾分鐘。
- 代理可執行——你可以無人看管地跑它;只有透過
scripts/hitl-loop.template.sh才需要人在迴圈中。
如果你發現自己在這個指令存在之前就讀程式碼建理論,停下來——直接跳到假設正是本技能要防止的失敗模式。 沒有能變紅的指令,就沒有第二階段。
第二階段——重現 + 最小化
執行迴圈。看它變紅——bug 出現了。
確認:
- 迴圈產生的是使用者描述的那個失敗模式——不是碰巧在附近的其他失敗。錯誤的 bug = 錯誤的修復。
- 失敗在多次執行間可重現(或對非確定性 bug,重現率高到足以針對它除錯)。
- 你已擷取確切的症狀(錯誤訊息、錯誤輸出、緩慢的時序),讓後續階段可以驗證修復確實對症下藥。
最小化
一旦它變紅,把重現縮到仍會變紅的最小情境。一次一個地砍掉輸入、呼叫者、設定、資料與步驟,每次砍完重跑迴圈——只保留對失敗真正承重的東西。
為什麼要費心:最小重現會縮小第三階段的假設空間(剩下可懷疑的活動部件更少),並成為第五階段的乾淨回歸測試。
完成條件是每個剩餘元素都承重——移除任何一個都會讓迴圈變綠。
在完成重現與最小化之前,不要往下走。
第三階段——提出假設
在測試任何假設之前,產生 3–5 個排序的假設。單一假設容易錨定在第一個看起來合理的想法上。
每個假設都必須可否證:說出它做出的預測。
格式:「如果 是原因,那麼 <改變 Y> 會讓 bug 消失 / <改變 Z> 會讓它更糟。」
如果你說不出預測,那假設只是一種感覺——丟棄它或把它磨利。
在測試之前,把排序後的清單給使用者看。 他們通常擁有能立刻重新排序的領域知識(「我們剛剛部署了 #3 的改動」),或知道他們已經排除的假設。便宜的檢查點,省下大把時間。不要被它卡住——如果使用者 AFK,就照你的排序往下走。
第四階段——插樁
每個探針都必須對應到第三階段的特定預測。一次只改一個變數。
工具偏好:
- 除錯器 / REPL 檢視,如果環境支援的話。一個中斷點勝過十個日誌。
- 具針對性的日誌,放在能區分假設的邊界上。
- 永遠不要「全部打日誌然後 grep」。
用獨特前綴標記每個除錯日誌,例如 [DEBUG-a4f2]。最後的清理變成單次 grep。未標記的日誌存活;標記的日誌陣亡。
效能分支。 對效能回歸,日誌通常是錯的。改成:先建立基線量測(時序測試架、performance.now()、profiler、查詢計畫),再二分。先量測,後修復。
第五階段——修復 + 回歸測試
在修復之前寫回歸測試——但只有當它有正確的接縫時。
正確的接縫是測試在呼叫點實際發生的 bug 模式上運作的接縫。如果唯一可用的接縫太淺(bug 需要多個呼叫者時卻只有單一呼叫者測試、無法重現觸發 bug 的鏈的單元測試),在那裡的回歸測試會給你錯誤的信心。
如果沒有正確的接縫,那本身就是發現。 記下它。程式庫架構正在阻止 bug 被鎖定。把這個標記給下一階段。
如果正確的接縫存在:
- 把最小化的重現變成該接縫上的失敗測試。
- 看它失敗。
- 套用修復。
- 看它通過。
- 針對原始(未最小化)情境重跑第一階段的回饋迴圈。
第六階段——清理 + 事後檢討
宣告完成前必須完成:
- 原始重現不再重現(重跑第一階段迴圈)
- 回歸測試通過(或接縫缺失已記錄在案)
- 所有
[DEBUG-...]插樁已移除(grep 該前綴) - 一次性原型已刪除(或移到清楚標記的除錯位置)
- 正確的假設寫進 commit / PR 訊息——讓下一位除錯者學到
然後問:什麼能防止這個 bug? 如果答案涉及架構變更(沒有好的測試接縫、呼叫者糾纏不清、隱藏的耦合),把具體內容交接給 /improve-codebase-architecture 技能。在修復完成之後再提出建議,不要提前——你現在比開始時擁有更多資訊。