/click-path-audit — 行為流程審計
找出靜態程式碼閱讀遺漏的錯誤:狀態互動副作用、順序呼叫間的競態條件,以及默默地互相抵消的處理常式。
此技能解決的問題
傳統除錯檢查:
- 函式是否存在?(缺少連線)
- 是否崩潰?(執行階段錯誤)
- 是否回傳正確型別?(資料流)
但它不檢查:
- 最終 UI 狀態是否與按鈕標籤承諾相符?
- 函式 B 是否默默地撤銷了函式 A 剛剛做的事?
- 共用狀態(Zustand/Redux/context)是否有取消預期動作的副作用?
實際案例:一個「新郵件」按鈕呼叫了 setComposeMode(true) 然後 selectThread(null)。兩者各自正常運作。但 selectThread 有一個副作用會重設 composeMode: false。按鈕完全沒反應。系統性除錯發現了 54 個錯誤——但這個被遺漏了。
運作方式
針對目標區域中的每個互動接觸點:
1. 識別處理常式(onClick、onSubmit、onChange 等)
2. 追蹤處理常式中的**每個**函式呼叫,**按順序**
3. 對**每個**函式呼叫:
a. 它**讀取**了哪些狀態?
b. 它**寫入**了哪些狀態?
c. 它對共用狀態是否有**副作用**?
d. 它是否作為副作用**重設/清除**任何狀態?
4. 檢查:後面的呼叫是否**撤銷**了前面呼叫的狀態變更?
5. 檢查:**最終**狀態是否符合使用者從按鈕標籤得到的期望?
6. 檢查:是否存在競態條件(以錯誤順序解析的非同步呼叫)?
執行步驟
步驟 1:映射狀態儲存
在審計任何接觸點之前,為每個狀態儲存動作建立副作用映射:
對於範圍內的每個 Zustand 儲存 / React context:
對於每個動作/設定器:
- 它設定了哪些欄位?
- 它是否作為副作用**重設**其他欄位?
- 記錄:actionName → {sets: [...], resets: [...]}
這是關鍵參考。「新郵件」錯誤在不知道 selectThread 會重設 composeMode 之前是看不見的。
輸出格式:
STORE: emailStore
setComposeMode(bool) → sets: {composeMode}
selectThread(thread|null) → sets: {selectedThread, selectedThreadId, messages, drafts, selectedDraft, summary} RESETS: {composeMode: false, composeData: null, redraftOpen: false}
setDraftGenerating(bool) → sets: {draftGenerating}
...
DANGEROUS RESETS(會清除不屬於自己狀態的動作):
selectThread → resets composeMode(屬於 setComposeMode)
reset → 重設所有東西
步驟 2:審計每個接觸點
針對目標區域中的每個按鈕/切換/表單提交:
TOUCHPOINT: [按鈕標籤] 在 [元件:行號]
HANDLER: onClick → {
call 1: functionA() → sets {X: true}
call 2: functionB() → sets {Y: null} RESETS {X: false} ← 衝突
}
EXPECTED: 使用者看到 [按鈕標籤承諾的內容描述]
ACTUAL: X 是 false,因為 functionB 重設了它
VERDICT: BUG — [描述]
檢查每一種錯誤模式:
模式 1:序列撤銷
handler() {
setState_A(true) // 設定 X = true
setState_B(null) // 副作用:重設 X = false
}
// 結果:X 是 false。第一次呼叫沒有意義。
模式 2:非同步競態
handler() {
fetchA().then(() => setState({ loading: false }))
fetchB().then(() => setState({ loading: true }))
}
// 結果:最終的 loading 狀態取決於哪個先解析
模式 3:閉包過時
const [count, setCount] = useState(0)
const handler = useCallback(() => {
setCount(count + 1) // 捕獲過時的 count
setCount(count + 1) // 相同的過時 count — 只增加 1,不是 2
}, [count])
模式 4:遺失狀態轉換
// 按鈕顯示「儲存」,但處理常式只驗證,從未實際儲存
// 按鈕顯示「刪除」,但處理常式只設定一個標誌,從未呼叫 API
// 按鈕顯示「傳送」,但 API 端點已被移除/損壞
模式 5:條件死路
handler() {
if (someState) { // someState 在此時**永遠是 false**
doTheActualThing() // 永遠不會執行到
}
}
模式 6:useEffect 干擾
// 按鈕設定 stateX = true
// 一個 useEffect 監聽 stateX 並將其重設為 false
// 使用者什麼都沒看到
步驟 3:回報
針對每個發現的錯誤:
CLICK-PATH-NNN: [嚴重性: CRITICAL/HIGH/MEDIUM/LOW]
Touchpoint: [按鈕標籤] 在 [檔案:行號]
Pattern: [Sequential Undo / Async Race / Stale Closure / Missing Transition / Dead Path / useEffect Interference]
Handler: [函式名稱或內聯]
Trace:
1. [呼叫] → sets {欄位: 值}
2. [呼叫] → RESETS {欄位: 值} ← 衝突
Expected: [使用者期望的結果]
Actual: [實際發生的結果]
Fix: [具體修復方式]
範圍控制
此審計成本高昂。請適當地設定範圍:
- 全應用審計: 在產品上線或重大重構後使用。可針對每個頁面平行啟動代理。
- 單頁審計: 在新頁面建置後,或使用者回報某個按鈕損壞時使用。
- 儲存焦點審計: 在修改 Zustand 儲存後使用——審計所有使用到已變更動作的消費者。
全應用審計建議的代理群組:
Agent 1: 映射**所有**狀態儲存(步驟 1)——這是所有其他代理的共用上下文
Agent 2: 儀表板(Tasks、Notes、Journal、Ideas)
Agent 3: 聊天(DanteChatColumn、JustChatPage)
Agent 4: 電子郵件(ThreadList、DraftArea、EmailsPage)
Agent 5: 專案(ProjectsPage、ProjectOverviewTab、NewProjectWizard)
Agent 6: CRM(所有子分頁)
Agent 7: 個人資料、設定、Vault、通知
Agent 8: 管理套件(所有頁面)
Agent 1 必須優先完成。其輸出是所有其他代理的輸入。
何時使用
- 系統性除錯發現「沒有錯誤」但使用者回報 UI 損壞時
- 修改任何 Zustand 儲存動作後(檢查所有呼叫者)
- 任何觸及共用狀態的重構後
- 釋出前,針對關鍵使用者流程
- 當一個按鈕「完全沒反應」時——這是專門的工具
何時不使用
- 針對 API 層級的錯誤(錯誤的回應格式、遺漏的端點)——使用
/superpowers:systematic-debugging - 針對樣式/排版問題——使用視覺檢查
- 針對效能問題——使用效能分析工具
與其他技能的整合
- 在
/superpowers:systematic-debugging之後執行(該技能找出其他 54 種錯誤類型) - 在
/superpowers:verification-before-completion之前執行(該技能驗證修復是否有效) - 饋入
/superpowers:test-driven-development——這裡找到的每個錯誤都應該有對應的測試
範例:啟發此技能的錯誤
ThreadList.tsx 的「新郵件」按鈕:
onClick={() => {
useEmailStore.getState().setComposeMode(true) // ✓ 設定 composeMode = true
useEmailStore.getState().selectThread(null) // ✗ 重設 composeMode = false
}}
儲存定義:
selectThread: (thread) => set({
selectedThread: thread,
selectedThreadId: thread?.id ?? null,
messages: [],
drafts: [],
selectedDraft: null,
summary: null,
composeMode: false, // ← 這個沈默的重設毀了按鈕
composeData: null,
redraftOpen: false,
})
系統性除錯遺漏了它,因為:
- 按鈕有 onClick 處理常式(不是死的)
- 兩個函式都存在(沒有遺漏的連線)
- 兩個函式都不會崩潰(沒有執行階段錯誤)
- 資料型別正確(沒有型別不符)
點擊路徑審計捕捉到了它,因為:
- 步驟 1 映射出
selectThread會重設composeMode - 步驟 2 追蹤處理常式:第一次呼叫設定 true,第二次呼叫重設 false
- 判決:序列撤銷——最終狀態與按鈕意圖矛盾