大多數審查檢查清單都是安慰劑

如果你的團隊有一份程式碼審查檢查清單,它很有可能躺在沒人打開的維基頁面上。上面大概寫著「check for off-by-one errors」和「verify error handling」之類的話。這些話都是對的。但也過於籠統,不足以改變行為。

一份告訴你「check for bugs」的檢查清單不是檢查清單。它只是提醒你bug存在而已。

Fagan inspection中的檢查清單有不同的用途。它不是一份擔憂清單,而是一種結構化工具,將審查者的注意力引向在實際被審程式碼庫中觀察到的特定缺陷類別。它由數據構建,針對工件類型量身訂製,並在強制性個人準備階段使用。如果使用得當,它是結構化檢查能發現非正式審查三到四倍缺陷的主要原因之一。

什麼讓檢查清單「結構化」

「結構化」這個詞在這裡很重要。結構化檢查清單不是更長的檢查清單,而是具有特定設計屬性的檢查清單。

首先,它源自逃逸缺陷數據。條目來自實際進入生產環境的bug,而不是來自通用最佳實踐文件。如果你最近三次事故都涉及非同步清理中的race conditions,那麼該類別就會有自己的檢查清單條目。如果null dereferences兩年來都不是問題,那麼該條目就會被移除。

其次,它僅限於被審查的工件類型。Fagan inspection最初對需求文件、設計文件、原始碼和測試計劃使用不同的檢查清單。每個工件都有不同的缺陷類別。設計文件檢查清單詢問介面一致性和耦合度。原始碼檢查清單詢問邊界條件和資源清理。將兩者混在一起會稀釋兩者。

第三,它在個人準備期間使用,而不是在會議期間。每位檢查員在小組聚會之前,獨自帶著檢查清單閱讀材料。檢查清單塑造了每個人在孤立閱讀程式碼時看到的內容。它不是共享參考文件,而是個人透鏡。

第四,它夠短,便於使用。四十個條目的檢查清單是目錄,不是工具。Fagan建議每個工件類型大約十到十五個條目。這種限制迫使進行優先順序排序。保留重要的類別,丟棄雜訊。

如何從自己的數據中構建

構建結構化檢查清單的最佳方法是查看已經出了什麼問題。以下是一個實用的流程。

從你的事故追蹤器、bug資料庫或事後分析記錄開始。提取最近二十個逃脫審查並進入生產或測試階段的缺陷。按根本原因而非症狀對每個缺陷進行分類。「Page crashed」是症狀。「Missing null check after external API response」是根本原因。

將根本原因分組為類別。你可能會發現80%的逃逸缺陷落入三到五個類別。這些就是你的檢查清單條目。

對每個類別,寫出一個具體的觸發問題,而不是模糊的提醒。「Check for nulls」是模糊的。「For every function that calls an external API, verify the response is validated before use」是一個觸發問題。它告訴審查者確切要找什麼以及在哪裡找。

以下是一個具體例子。假設你的團隊發布Python服務,你分析了最近二十個生產問題。你發現如下分布:

  • 6個問題:外部呼叫缺少錯誤處理
  • 5個問題:資料庫 transaction邊界錯誤
  • 4個問題:分頁邏輯中的off-by-one
  • 3個問題:快取資料中的race conditions
  • 2個問題:記錄敏感資料

你的檢查清單應該有五個條目,每個類別一個。每個條目應該是一個與你的特定模式綁定的觸發問題。

#!/usr/bin/env python3
"""Build a structured review checklist from escaped defect data."""

from collections import Counter
from dataclasses import dataclass
from typing import List


@dataclass
class EscapedDefect:
    id: str
    root_cause: str
    trigger_question: str


def build_checklist(defects: List[EscapedDefect], max_items: int = 10) -> List[str]:
    """Build a Fagan-style checklist from escaped defect data.

    Groups by root cause, sorts by frequency, and returns trigger
    questions for the top categories.
    """
    counts = Counter(d.root_cause for d in defects)
    top_causes = counts.most_common(max_items)

    # Map root causes back to their most representative trigger question
    cause_to_question = {}
    for d in defects:
        if d.root_cause not in cause_to_question:
            cause_to_question[d.root_cause] = d.trigger_question

    checklist = []
    for cause, count in top_causes:
        question = cause_to_question[cause]
        checklist.append(f"[{count}x] {question}")

    return checklist


# Example: escaped defects from a Python web service
DEFECTS = [
    EscapedDefect("BUG-101", "missing-external-error-handling",
                  "For every external API call, is the response validated before use?"),
    EscapedDefect("BUG-102", "missing-external-error-handling",
                  "For every external API call, is the response validated before use?"),
    EscapedDefect("BUG-103", "missing-external-error-handling",
                  "For every external API call, is the response validated before use?"),
    EscapedDefect("BUG-104", "transaction-boundary-error",
                  "Does every database write have the correct transaction scope?"),
    EscapedDefect("BUG-105", "transaction-boundary-error",
                  "Does every database write have the correct transaction scope?"),
    EscapedDefect("BUG-106", "pagination-off-by-one",
                  "For every pagination query, are the limit and offset tested at boundaries?"),
    EscapedDefect("BUG-107", "cache-race-condition",
                  "For every cached value, is there a clear invalidation or TTL strategy?"),
    EscapedDefect("BUG-108", "sensitive-data-in-logs",
                  "Does any log statement include user data, tokens, or PII?"),
]

if __name__ == "__main__":
    checklist = build_checklist(DEFECTS, max_items=5)
    print("Structured Review Checklist")
    print("=" * 40)
    for item in checklist:
        print(f"  [ ] {item}")

針對你自己的bug數據執行此指令碼,你將擁有一份綁定到你實際失效模式而非他人失效模式的檢查清單。

審查會議實際是什麼樣子

拿著檢查清單,審查會議有特定的節奏。

在個人準備期間,每位檢查員以大約每小時100到150行的速度獨自閱讀材料。他們將檢查清單用作透鏡,而不是腳本。他們不會按順序逐項過檢查清單。他們自然地閱讀程式碼,當遇到與檢查清單類別匹配的模式時,會停下來仔細審查。

檢查清單不能替代閱讀。它是大腦可能會跳過的事物的模式匹配器。

在檢查會議中,閱讀者大聲轉述程式碼。當檢查員發現潛在缺陷時,會立即提出。檢查清單類別提供共享詞彙。檢查員不必說「這看起來不對」,而是可以說「這看起來像是transaction 邊界錯誤,類別二」。主持人記錄。作者傾聽。沒有人提出修復方案。

會議期間不參考檢查清單。到那時,檢查員已經將其內化。會議是為了交叉驗證每個人獨立發現的內容。

為什麼通用檢查清單會失敗

大多數嘗試使用檢查清單的團隊會放棄,因為他們使用了錯誤的類型。

從部落格文章中複製來的通用檢查清單沒有情感分量。它要求審查者尋找可能在其程式碼庫中從未發生過的事情。審查者瀏覽一遍,看不到熟悉的內容,然後回到他們一貫閱讀diff的方式。

由逃逸缺陷構建的結構化檢查清單則不同。每個條目代表一個耗費了真實時間的真實事故。審查者知道這些bug,因為他們看過事後分析。檢查清單將審查行為與具體、難忘的錯誤聯繫起來。

另一個常見錯誤是將檢查清單視為會議工具而非準備工具。如果你在審查會議中拿出檢查清單,它就變成了集體瀏覽的腳本。所有人讀同一個條目,看同一段程式碼,收斂到同樣的明顯觀察。檢查清單的力量在於個人準備,六個人獨立應用相同類別並發現不同問題。

大多數團隊忽視的維護負擔

檢查清單不是紀念碑。它是比程式碼腐爛得更快的活文件。

如果你每次出問題都增加一個檢查清單條目但從不刪除,六個月後你會有四十個條目。到那時,審查者開始將其當作壁紙對待。

Fagan建議每隔幾次檢查後就審查檢查清單本身。移除最近五次審查中未觸發發現的條目。僅對逃逸到生產環境的新缺陷類別增加條目。將總數保持在十五個以下。如果你無法保持簡短,說明你沒有在做優先順序排序。

以下是一個基於歷史發現資料修剪檢查清單的輕量指令碼:

#!/usr/bin/env python3
"""Prune a review checklist: remove items that no longer trigger findings."""

from dataclasses import dataclass
from typing import List, Dict


@dataclass
class ChecklistItem:
    category: str
    trigger_question: str
    findings_last_5_reviews: int


def prune_checklist(items: List[ChecklistItem], min_findings: int = 1) -> List[ChecklistItem]:
    """Remove checklist items that have not produced findings recently.

    A Fagan-style checklist should be short enough to be usable.
    Items that sit idle waste attention.
    """
    kept = [item for item in items if item.findings_last_5_reviews >= min_findings]
    removed = [item for item in items if item.findings_last_5_reviews < min_findings]

    print(f"Kept {len(kept)} items, removed {len(removed)} items")
    for item in removed:
        print(f"  REMOVED: [{item.category}] {item.trigger_question}")

    return kept


# Example: current checklist with finding counts from the last 5 reviews
CURRENT_CHECKLIST = [
    ChecklistItem("missing-external-error-handling",
                  "For every external API call, is the response validated before use?", 4),
    ChecklistItem("transaction-boundary-error",
                  "Does every database write have the correct transaction scope?", 3),
    ChecklistItem("pagination-off-by-one",
                  "For every pagination query, are the limit and offset tested at boundaries?", 0),
    ChecklistItem("cache-race-condition",
                  "For every cached value, is there a clear invalidation or TTL strategy?", 1),
    ChecklistItem("sensitive-data-in-logs",
                  "Does any log statement include user data, tokens, or PII?", 0),
]

if __name__ == "__main__":
    pruned = prune_checklist(CURRENT_CHECKLIST, min_findings=1)
    print("\nActive checklist:")
    for item in pruned:
        print(f"  [ ] [{item.findings_last_5_reviews}x] {item.trigger_question}")

每季安排一次這種審查。過時的檢查清單比沒有檢查清單更糟,因為它訓練審查者忽視這個工具。

權衡:資料與速度

從缺陷數據中構建結構化檢查清單需要時間。你需要準確的事故分類。你需要保持清單簡短的紀律。你需要一個保持其最新的流程。

通用檢查清單五分鐘寫完,五週後變得無關緊要。

結構化版本建立較慢但使用較快。有十五個針對性觸發問題的審查者比有四十個通用提醒的審查者更高效地掃描程式碼。具體性節省時間。

真正的成本是組織性的。你需要足夠詳細的逃逸缺陷記錄來提取根本原因。許多團隊沒有。他們的bug追蹤器標題像「user report: page broken」,沒有後續分析。沒有根本原因數據,你無法從證據構建檢查清單。你只能複製別人的。

常見問題

What is a structured checklist-based review?

一種審查流程,每位檢查員在小組檢查會議前的強制性個人準備期間,使用基於實際逃逸缺陷數據構建的檢查清單。檢查清單包含針對性觸發問題而非通用提醒。

How is this different from a normal code review checklist?

普通檢查清單通常是通用的,從範本複製,在審查期間隨意參考。結構化檢查清單源自團隊特定的缺陷歷史,限於工件類型,並在固定閱讀速度下的集中個人準備期間使用。

How many items should a structured checklist have?

每個工件類型十到十五個條目。超過這個數量,審查者開始瀏覽。少於五個,你可能遺漏了類別。

How often should the checklist be updated?

每三到五次檢查後,或每季一次。移除未產生發現的條目。僅對新的逃逸缺陷類別增加條目。

Can this work without a full Fagan inspection process?

可以。檢查清單本身是Fagan方法中最可移植的部分。要求個人準備,給審查者一份基於你們數據構建的結構化檢查清單,並與閱讀者一起進行限時會議。即使沒有完整的六階段儀式,你也會發現比非正式審查更多的缺陷。

從一個模組和一個季度開始

你不需要徹底改革整個審查流程。選擇一個在過去六個月中有過生產缺陷的模組。收集根本原因。構建一個五條目檢查清單。要求兩位審查者在任何小組討論之前花三十分鐘閱讀程式碼和檢查清單。

記錄他們發現的內容。與同一程式碼上常規拉取請求審查發現的內容進行比較。這唯一的一次比較將告訴你結構化檢查清單是否值得你的團隊付出努力。

如果數據說是,擴展到一個更多模組。如果數據說否,你的非正式審查已經足夠好,或者你的缺陷數據不夠詳細,無法構建有用的檢查清單。無論哪種情況,你得到的是真實測量而非借來的觀點。