同じバグを二度修正するのはプロセスの失敗だ
どのチームにも、何度も繰り返し出現する欠陥がある。ページネーションのオフバイワン。認証ミドルウェアの欠落したnullチェック。三スプリント前に誰かが「修正した」チェックアウトの競合状態。
同じバグを三回書いたわけではない。同じ原因を持つ三つの異なるバグを書いたのだ。修正は症状に対処した。原因は隠れたままだった。
フェイガン検査は、欠陥が流出する前に発見することを目的として構築された。ほとんどのチームは再作業フェーズで止まる。作成者が記録された問題を修正し、モデレーターが修正を検証し、全員が先に進む。これは間違いだ。フォローアップフェーズこそが因果分析が属する場所だ。これを飛ばすと、次の検査も同じ種類の欠陥のために予定を立てることになる。
因果分析が実際に意味するもの
因果分析は根本原因分析ではない。根本原因分析は「どのコード行が失敗し、なぜか」を問う。因果分析は「プロセスのどの部分が、この欠陥カテゴリーの存在を許したのか」を問う。
nullポインタ例外の根本原因は「nullチェックを忘れた」だ。因果分析の所見は「レビューチェックリストにnull安全性が含まれておらず、lintルールが未チェックのデリファレンスを許している」だ。一方はバグを修正する。もう一方は工場を修正する。
フェイガン検査では、因果分析は再作業後に行われる。モデレーターが欠陥をカテゴリー別にグループ化し、プロセスレベルの原因を特定する短いセッションを主導する。出力はコードの変更ではない。プロセスの変更だ。更新されたチェックリスト、新しいlintルール、トレーニングの欠如、または修正された入力基準。
仕組み:実行方法
検査ログから始める。各欠陥にはすでに四つのフィールドが必要だ。場所、重大度、種類、説明。因果分析中に五つ目を追加する。プロセスの原因。
モデレーターが欠陥を種類別にグループ化する。十二個の欠陥のうち三つが境界条件エラーなら、それはパターンだ。二つが同じモジュールからのAPI誤用エラーなら、それもパターンだ。パターンがシグナルだ。個別の欠陥はノイズだ。
各パターンについて三つの質問をする。
- このカテゴリーを検査前に防ぐことができたか?
- 既存の防止メカニズムがなぜ見逃したのか?
- 次回このカテゴリーを防ぐ最も安価な変更は何か?
三つ目の質問でほとんどのチームが間違う。彼らはアーキテクチャの書き換えを提案する。それは希望的観測だ。目標は、そのカテゴリーを排除する最も小さなプロセス変更だ。
境界条件のパターンは、レビューチェックリストに「オフバイワンおよび境界チェック」を追加することを意味するかもしれない。API誤用のパターンは、静的解析ルールを追加することを意味するかもしれない。欠落したエラーハンドリングのパターンは、完了の定義を更新してエラーパステストを要求することを意味するかもしれない。
実用的な因果分析トラッカー
以下は、検査ログを受け取り、欠陥を種類別にグループ化し、プロセスレベルの原因を尋ねるPythonスクリプトだ。
#!/usr/bin/env python3
"""
Run causal analysis on a Fagan inspection log.
Reads a JSON log, groups defects by type, and emits a causal analysis report.
"""
import json
import argparse
from collections import defaultdict
from dataclasses import dataclass, field
from typing import List, Dict
@dataclass
class Defect:
location: str
severity: str
defect_type: str
description: str
@dataclass
class CausalPattern:
defect_type: str
count: int
locations: List[str] = field(default_factory=list)
process_cause: str = ""
proposed_fix: str = ""
def load_log(path: str) -> List[Defect]:
with open(path, "r") as f:
raw = json.load(f)
return [Defect(**item) for item in raw]
def analyze_patterns(defects: List[Defect]) -> List[CausalPattern]:
groups: Dict[str, List[Defect]] = defaultdict(list)
for d in defects:
groups[d.defect_type].append(d)
patterns = []
for dtype, items in groups.items():
patterns.append(CausalPattern(
defect_type=dtype,
count=len(items),
locations=[d.location for d in items],
))
return sorted(patterns, key=lambda p: p.count, reverse=True)
def prompt_causal_input(patterns: List[CausalPattern]) -> List[CausalPattern]:
print("=== CAUSAL ANALYSIS SESSION ===")
print("For each pattern, identify the process cause and the cheapest fix.\n")
for p in patterns:
print(f"Pattern: {p.defect_type} ({p.count} occurrence(s))")
print(f"Locations: {', '.join(p.locations)}")
p.process_cause = input("Process cause: ").strip()
p.proposed_fix = input("Cheapest prevention fix: ").strip()
print()
return patterns
def emit_report(patterns: List[CausalPattern], output_path: str):
report = {
"summary": {
"total_patterns": len(patterns),
"total_defects": sum(p.count for p in patterns),
},
"patterns": [
{
"type": p.defect_type,
"count": p.count,
"locations": p.locations,
"process_cause": p.process_cause,
"proposed_fix": p.proposed_fix,
}
for p in patterns
],
}
with open(output_path, "w") as f:
json.dump(report, f, indent=2)
print(f"Report written to {output_path}")
def main():
parser = argparse.ArgumentParser(description="Causal analysis for Fagan inspections")
parser.add_argument("log", help="Path to inspection log JSON")
parser.add_argument("--output", default="causal_report.json", help="Output report path")
args = parser.parse_args()
defects = load_log(args.log)
patterns = analyze_patterns(defects)
if not patterns:
print("No defects found. Nothing to analyze.")
return
patterns = prompt_causal_input(patterns)
emit_report(patterns, args.output)
if __name__ == "__main__":
main()
検査ログを inspection_log.json として保存する。
[
{"location": "src/auth.py:42", "severity": "major", "defect_type": "null-safety", "description": "Missing null check on user object"},
{"location": "src/orders.py:88", "severity": "minor", "defect_type": "boundary", "description": "Off-by-one in pagination limit"},
{"location": "src/auth.py:67", "severity": "major", "defect_type": "null-safety", "description": "Unchecked token decode result"}
]
python causal_analysis.py inspection_log.json を実行すると、スクリプトがプロセスの原因を特定する手順を案内してくれる。生成されたレポートは、次の振り返りやプロセス改善サイクルの入力となる。
なぜほとんどのチームがこれを飛ばすのか
因果分析は、すでに高価なプロセスに時間を追加する。250行の標準的なフェイガン検査には8〜12人時かかる。因果分析はさらに30〜60分追加する。
バックログがあるとき、この追加時間は無駄に感じられる。しかし、因果分析が欠陥カテゴリーの再発を一度でも防げば、そのカテゴリーが次に現れなかったときに元が取れる。
より困難な問題は正直さだ。因果分析はしばしば、欠陥が存在したのはチームがステップを飛ばしたからだと明らかにする。コードは検査前にテストされていなかった。レビュアーはチェックリストを使わなかった。チェックリスト自体が不完全だ。
これらの所見は不快なことがある。因果分析を責任の割り当てとして扱うチームは、正直な答えを得られなくなる。モデレーターは、それを説責の場ではなくプロセス改善として位置づけなければならない。
本当のトレードオフ:速度対学習
フェイガン検査後には二つの選択肢がある。修正を検証して先に進むことでループを閉じる。または、修正を検証して欠陥がなぜ存在したかを学ぶことでループを閉じる。
最初の選択肢は今日の方が速い。二番目の選択肢は今後六カ月の間に速くなる。
フェイガン検査から最大の価値を得るチームは、検査ログをデータセットとして扱う。欠陥パターンはフィードバックだ。それを無視することは、テストスイートを実行して失敗を見ないのと同じだ。
すべての欠陥カテゴリーが因果分析を必要とするわけではない。一度だけのタイポにはプロセス変更は不要だ。しかし、あるカテゴリーが二回連続の検査に現れたら、それはコード問題のふりをしたプロセス問題だ。
影響が最も大きいモジュールから始める
すべての検査で因果分析を実行する必要はない。本番インシデントや流出欠陥が最も多いモジュールを選ぶ。完全なフェイガン検査を実行し、ログを収集し、因果分析に三十分費やす。
初めてやるとき、提案される修正は明らかだろう。チェックリストを更新する。lintルールを追加する。パターンについての短いチームノートを書く。三回目の検査までには、すでに分析したカテゴリーで欠陥が少なくなっているはずだ。
数値が下がらなければ、提案された修正が曖昧すぎる。「もっと注意深くする」はプロセス変更ではない。「CIでnull安全性リンターを実行する」はそうだ。
時間の経過とともに検査ごとの欠陥カテゴリーを追跡する。因果分析が機能していれば、繰り返しのカテゴリーは消える。消えなければ、原因の特定を間違っているか、修正を実施できていないかのどちらかだ。
FAQ
フェイガン検査における因果分析とは何か?
因果分析は、検査後のステップで、チームが発見された欠陥を調べ、カテゴリー別にグループ化し、プロセスレベルの原因を特定するものだ。目標は、個々の欠陥を修正するだけでなく、チェックリスト、ツール、または慣行を変更して再発を防ぐことだ。
因果分析と根本原因分析はどう違うのか?
根本原因分析は、欠落したnullチェックなど、単一の欠陥が発生した具体的な理由を特定する。因果分析は、欠落したチェックリスト項目や施行されていないlintルールなど、プロセスがその欠陥カテゴリー全体を書くことを許可した理由を特定する。
因果分析はいつ実行すべきか?
フェイガン検査の再作業とフォローアップフェーズの後、欠陥がまだ新鮮なうちに実行する。複数の欠陥に現れるパターンや、以前の検査に現れたパターンに焦点を当てる。一度だけの欠陥がプロセス変更を正当化することはめったにない。
因果分析が機能しているかどうかをどうやって知るか?
時間の経過とともに検査を横断して欠陥カテゴリーを追跡する。因果分析が効果的なら、繰り返しカテゴリーの頻度は下がるはずだ。同じカテゴリーが出現し続けるなら、提案された修正は間違っているか、実施されていないかのどちらかだ。
次の検査ログで実行する
最後の検査ログを見つける。欠陥を種類別にグループ化する。トップカテゴリーについて、次回それを防ぐための最も安価なプロセス変更は何かを問う。
その変更を書き留める。担当者を割り当てる。次の検査で検証する。それが因果分析だ。他のすべてはただのバグ修正に過ぎない。