このファイルは
/review-respondで記録した未統合の再発防止パターンの一時置き場です。/review-respondPhase 6(随時)または/harness-audit(定期点検時)の実施でpj-checklistへ統合され、本ファイルからは削除されます。 過去エントリは git 履歴 (git log -- .claude/review-lessons.md) を参照してください。 エントリ単位は### <カテゴリ>: ...見出しで数えます(行数ではない)。
- 問題: 「レビュー提出済みユーザーのみ」という仕様を
latestReviews由来であることだけで担保していたが、gh pr view --json latestReviewsにはviewer自身の未提出(state == "PENDING")レビューが含まれることがあり、未提出のレビュアーが候補に混じりうる - 対策:
latestReviewsを「提出済みレビュー」として扱う処理ではstate ~= "PENDING"を明示的に確認する。docstringに書いた入力データの前提は、コード側の条件として保証する - 該当箇所: lua/fude/ui/format.lua
- 問題: gh APIレスポンス由来のネストフィールド(
review.author.login等)をx and x.yで参照していたが、JSON nullはvim.NIL(truthyなuserdata)にデコードされるためindexエラーになりうる。util.is_nullはvim.NILを参照するため、純粋性チェック対象モジュール(*/format.lua・*/data.lua)では使えない - 対策: 純粋モジュールでは
type(x) == "table" and x.y or nilでガードする。外部APIレスポンスのネスト参照を書くとき、既存の同パターン箇所(同ファイル内の類似関数)も同時に確認する - 該当箇所: lua/fude/ui/format.lua, lua/fude/overview.lua
- 問題: 「空配列は渡さないこと」をdocstringの注意書きだけで担保していた。空のLua tableは
vim.json.encodeでJSONオブジェクト({})にエンコードされるため、破ると不正なペイロードをAPIに送る - 対策: 呼び出し側の規約に依存せず、関数冒頭で空入力を弾いてエラーcallbackする。配列をJSONエンコードする関数では空テーブルがオブジェクトになるLuaの仕様を常に考慮する
- 該当箇所: lua/fude/gh.lua
- 再発 (PR #173, 2026-09-03): 「
#を含むパスは非対応」をdoc/fude.txtのLimitationsに書くだけで、コード側は#入りパスをそのまま抽出・添付していた(ghが#をalt区切りとして分断し誤添付になる)。「非対応」と文書化した入力は、コードでも検出して安全側(未処理のまま維持・スキップ)に倒すこと
- 問題: inline modeでresolvedコメントのboxを隠す際、行末フォールバックの
[resolved]判定を「隠した部分集合(hidden_resolved)」だけで行っていたため、同一行に未解決コメントが混在してもall_comments_resolvedが常にtrueになり、誤って[resolved]が表示された。「この行は全部resolvedか」という集約述語に行の一部だけを渡して意味が変わっていた - 対策: 集約述語(all_X / every_X 等)は、その述語が論理的に対象とする集合の全体(行の全コメント等)に対して評価する。フィルタ後の部分集合を渡すと述語の意味が壊れる。あわせて、要素を振り分けるフィルタ条件と集約述語は同じフィールド(
is_resolved)で判定し、食い違いを構造的に排除する - 該当箇所: lua/fude/ui/extmarks.lua
- 問題: retention pruneを通る実ロードパスのテストで、fixtureの
saved_atをハードコードした過去日付にしていたため、日付経過でretention window(30日)から外れテストが壊れた。動的なos.date()(現在時刻)への修正も「実行時刻依存で再現性が落ちる」とレビュー指摘を受けた - 対策: 実時刻(
os.time())と比較される経路を通るfixtureのタイムスタンプは、固定の十分未来の日付(例:2126-01-01T00:00:00Z)を使う。過去日付のハードコードは時限爆弾、現在時刻の動的生成は再現性低下。時刻を注入できる純粋関数(prune(t, now, days)等)のテストは固定nowを渡して書く - 該当箇所: tests/fude/drafts_spec.lua
- 問題: (1) angle-bracket入力形式(
](<file://...>))を後から追加した際、bare形式ではパターン構文上通らない)がangle形式では通るようになり、出力正規化と組み合わさって「ghが書き換えられない参照」を生んだ。(2) フェンスの開き判定(info string可)をそのまま閉じ判定にも使ったため、フェンス内の```luaのような行で誤って閉じた - 対策: 同じデータに対する記法を追加するとき、既存形式が構文上(暗黙に)排除していた文字・ケースを列挙し、新形式でも同じ制約が成立するかを確認する(「パターンが通さないから安全」は形式追加で崩れる)。開始と終了で許される構文が異なる構造(fence、heredoc等)は閉じ判定を仕様に沿って別に書く
- 該当箇所: lua/fude/pr.lua
- 問題: シェルスクリプトで
jq -cによるJSON生成後、生成した行が実際にファイル末尾に追記されたかをtail -n 1で再読み込みして比較検証していたが、(1)jq -cは出力が単一行のcompact JSONになることを既に保証しており、(2) 追記コマンド自体の失敗はset -euが既に検知するため、この検証は実質どの失敗も追加で捕捉していなかった。むしろ同時書き込み(人間側の操作等)が割り込むと、追記自体は成功しているのに検証だけ失敗する誤検知を生んでいた - 対策: 検証コードを書く前に「この検証は、依存しているプリミティブ(jqのフォーマット保証、
set -eのfail-fast等)が既にカバーしていない失敗モードを捕捉しているか」を確認する。捕捉対象が存在しないなら、検証の精度を上げる(マッチ窓を広げる等)のではなく検証自体を削除する - 該当箇所: contrib/skills/fude-watch/fude-watch-reply.sh
- 問題:
cond and nil or fallback/is_null(x) and nil or xは、中間値がnil/falseだと必ずfallback側に落ちるため「条件成立時にnilを返す」意図を表現できない。gh APIレスポンスのnull正規化(新規)とsuggest入力のcursor_pos分岐(既存)の2箇所で実バグ化しており、後者は数ヶ月間気づかれなかった。さらにcursor_posはstopinsertのトリガーも兼ねていたため、「値」だけ直してnilを渡すとinsertモード開始という隠れた挙動変化が起きた - 対策: 三項演算子のつもりでand-orを書くとき、真側の値がnil/falseになり得るなら明示的なif文か
util.null_to(v, default)を使う。レビュー時はand nil or・and false orをGrepして機械的に検出できる。パラメータの値を変える前に、受け側でそのパラメータがnil/非nilでゲートしている副作用(モード、フォーカス等)を確認し、テストもその観点で書く - 該当箇所: lua/fude/gh.lua, lua/fude/comments.lua, lua/fude/ui.lua, lua/fude/util.lua (null_to)
- 問題: 「API成功後に削除」の原則を守っていても、削除対象(draft等の永続データ)がリクエスト往復中にユーザー操作で更新され得る場合、成功callbackの無条件削除が新しい保存を消す。PR editフロート・comment browser下ペインのように入力UIがリクエスト中も開いたまま操作可能な設計では特に到達しやすい
- 対策: 非同期成功後にユーザーデータを削除するときは「送信時点の対象と同一か」を確認する。同一クロージャ内で完結するなら保存ハンドラでフラグを立てる方式(内容一致のエッジも塞げる)、クロージャをまたぐならsnapshot比較(
drafts.remove_if_unchanged)を使う。snapshotは本文ではなく保存ごとに変わるrevision(drafts.revision)にする。本文比較だと同じ本文の再保存を「変更なし」と見なして消す(PR #236)。修正時は同パターンの全サイト(drafts.remove等の成功後削除)をGrepで列挙し、UIが開いたままのサイトを優先する - 該当箇所: lua/fude/pr.lua, lua/fude/ui/comment_browser.lua, lua/fude/drafts.lua
- 問題:
diffoptのコメントとhelpで「nil to keep user's default」と案内していたが、setup()はvim.tbl_deep_extend("force", defaults, user_opts)でマージするため、setup({ diffopt = nil })はキー自体が落ちてデフォルトが適用される。デフォルトが非nilのオプションではnilによる無効化は不可能で、案内どおりに設定したユーザーは無効化できない - 対策: 「無効化の方法」を書くときは、デフォルト値がnilか非nilかを確認する。非nilデフォルト(table/文字列/数値)のオプションでは
{}/falseなど実際にマージ後に残る値で案内し、実装側の分岐(if opts.x then/ipairs(opts.x))がその値で意図どおりスキップされることを確認する。同じ既定値ブロックがREADME/doc/config.luaに複製されているので、文言変更時は3箇所をgrepする - 該当箇所: lua/fude/config.lua, doc/fude.txt, README.md
- 問題: 正規化したパスでバッファを探索し、その結果のバッファ番号をオープン後のウィンドウと比較していた。同じ実体ファイルを指す別名バッファが複数あると、探索が別のバッファを返して正常な処理を中断し、view復元やセンタリングを省略しうる
- 対策: パス探索では完全一致を別名一致より優先する。操作後の対象確認では再探索せず、ウィンドウが実際に表示しているバッファの正規化パスを要求した対象と直接比較する。別名が複数あるケースを回帰テストに含める
- 該当箇所: lua/fude/files.lua
- 問題: scope切り替えのno-op判定(同一scopeなら何もしない)が
state.scopeを見ていたが、この値は非同期のAPIコールバックが着いて初めて更新される。切り替え中に別のscopeを選ぶと、判定は古い値で行われ、保留中のリクエストを無効化しないまま早期returnし、後着のコールバックが最新の選択と異なるscopeを適用して完了コールバックまで発火した - 対策: 「現在の状態」と比較する判定を書くとき、その状態がいつ確定するかを確認する。確定が非同期なら、in-flightの遷移先を別に追跡し(
pending_switch等)、判定はそれと比較する。同一遷移先の再選択は保留中リクエストへ重複排除、別遷移先の選択は世代カウンタで保留中を無効化、という形で「最新の選択が必ず勝つ」を成立させる - 該当箇所: lua/fude/scope.lua
- 問題:
apply_commit_scopeはgit checkout(同期)を実行してからAPIを叩くが、state.scopeがcommitになるのは成功コールバック内。この間にstop()すると、HEAD復元の条件がstate.scope == "commit"だったため復元されず、レビュー停止後も作業ツリーがdetached HEADのまま残った - 対策: 非同期リクエストの前に同期的な副作用(checkout、ファイル作成等)を実行する関数では、その副作用を「リクエストが所有するリソース」として扱い、teardown側から参照できる形で公開する(
has_pending_*())。あわせて停止時は保留中リクエストを明示的に無効化し、遅れて返るコールバックがteardown後に動かないようにする - 該当箇所: lua/fude/scope.lua, lua/fude/init.lua
- 問題: 自動オープンに「カーソルがパネルを離れていたら実行しない」というstalenessガードを入れたが、同じ処理内で呼ぶ
refresh_preview(open_previewがsource windowへフォーカスを移す)が先に走るため、ユーザーが何もしていなくてもガードに引っかかり機能が常に沈黙した。さらに復元処理側も、close_previewで破棄済みのwindow handleと比較していたため、preview内にいたユーザーを別windowへ落とした - 対策: 「ユーザーが離れたか」を判定するガードを入れるときは、同じフローでプラグイン自身がフォーカスを移していないかを確認する。内部でフォーカスを移す関数は呼び出し元のフォーカスを復元して閉じる(破棄されるwindowが呼び出し元の場合は再生成後の対応windowへ移す)
- 該当箇所: lua/fude/scope.lua, lua/fude/preview.lua, lua/fude/ui/sidepanel.lua
- 問題: sidepanelに
next_entry = "j"/prev_entry = "k"のデフォルトを追加したが、キーマップ登録が後勝ちだったため、既にselect = "j"等を設定していたユーザーの明示設定が新デフォルトに黙って奪われる状態だった。さらに先勝ちに変えた後は、登録順が優先順位の仕様になったにもかかわらず、実際の登録順がドキュメントの列挙順と一致していなかった - 対策: 既存の設定キー集合に新しいデフォルト値を追加するときは、既存設定との衝突時にどちらが勝つかを決めて明示する(原則は既存設定優先)。優先順位がコードの実行順で決まる設計にしたら、その順序をドキュメントの列挙順と一致させ、衝突ケースのテストで固定する
- 該当箇所: lua/fude/ui/sidepanel.lua, doc/fude.txt
- 問題:
gh pr view <branch>はPRが無いときも認証切れ・ネットワーク障害のときも非ゼロで終了する。全エラーを「PRなし」に変換したため、ユーザーに「open PRがない」と誤った原因を伝えていた - 対策: 「見つからない」をエラー終了で表すCLIを呼ぶときは、not-foundのメッセージ(
no pull requests found等)だけを「対象なし」として扱い、それ以外はエラー内容を呼び出し元へ返して別の通知にする。判定はis_*_errorのような関数に切り出してテストする - 該当箇所: lua/fude/gh.lua, lua/fude/pr.lua
- 問題: picker表示前の同期処理で、候補branchごとに
git rev-list --countをvim.system():wait()で起動していた。候補が多いリポジトリではUIが目に見えて止まる - 対策: UI表示前に走る同期処理では、外部コマンドの起動回数が入力件数に比例しないようにする。gitなら
git log --decorate-refsやfor-each-ref --formatのように1回で必要な情報(所属と順序)を取れる呼び方を先に探す - 該当箇所: lua/fude/diff.lua
- 問題: 一時repoで
git commitには-c user.name=...を付けたが、git commit-treeには付け忘れた。ローカルはグローバル設定で通り、作者情報の無いCIだけAuthor identity unknownで落ちた - 対策: テストで一時repoを作ったら、直後に
git config user.name/user.emailをrepoローカルに設定する(コマンドごとの-c指定に頼らない)。ローカル確認はHOMEを空ディレクトリに向けてグローバル設定を切った状態でも行う - 該当箇所: tests/fude/diff_spec.lua
- 問題: keymapのrhsからユーザーコマンド名を探す際、(1) rhs全体を小文字化して比較したため実行時にE492になる
<cmd>fudereviewdiff<cr>まで一致し、(2) rhs中のどこに現れても直前が:/>/|なら一致としたためecho ':FudeReviewDiff'やlua print(">FudeReviewDiff")のような文字列中の出現まで一致した。「直前の記号を見る」を広げるほど引用文字列・コメントの除外(=Exパーサ)が必要になり収束しなかった - 対策: 大小文字を区別して解釈される識別子(コマンド名・パス等)は完全一致で探し、case-insensitiveは記法(
<Cmd>等)に限定する。文字列中の出現を排除したいときは「どこかに現れる」走査ではなく、構文上の決まった位置(rhs先頭のラッパー直後など)にアンカーして照合し、対象外になる形(|連結後など)はdocとテストで明示する。テストには小文字化した識別子・引用文字列中の出現が一致しないケースを含める - 該当箇所: lua/fude/palette.lua
- 問題: ウィンドウサイズを「希望サイズと最小値のmax」→「利用可能サイズとのmin」で求める際、利用可能サイズ側にも下限(例: 20桁/6行)を置いていたため、それより小さい端末では画面を超えるサイズを返した。「負や0にならない」ことだけ確認していて、「画面に収まる」ことを境界値で確認していなかった
- 対策: 下限は「通常時の最小サイズ」と「利用可能サイズの下限(1)」を分け、最後に適用するclampは必ず利用可能サイズ側にする。テストは
columns/linesが最小サイズ未満のケースを含め、返り値が利用可能サイズ以下かつ1以上であることを確認する - 該当箇所: lua/fude/palette.lua
- 問題: ピッカーがTelescope/snacks/
vim.ui.selectに分岐するのに、README・helpは「説明で検索できる」と無条件に書いていた。素のvim.ui.selectは番号選択で検索できないため、fallback経路ではドキュメントの約束を満たさなかった - 対策: 実装経路(picker種別・provider有無・モード)で振る舞いが変わる機能は、ドキュメント側で「どの経路で何ができるか」を経路ごとに書く。機能説明を書くときは、fallback経路でもその文が真かを確認する
- 該当箇所: README.md, doc/fude.txt
- 問題: local commit scopeを足した際、(1) 読み取り専用ガードを
comments.luaのfacadeにだけ置いたため、backend(local_sync)を直接呼ぶcomment browserからは書き込めた。(2)apply_gitsigns_base_for_bufferはGitHub側のstate.scopeしか見ておらず、同じ概念をlocal側はstate.local_session.scopeに持つため分岐に入らなかった。(3)init.stop()はHEAD復元失敗でセッションを維持する方針だったが、local/session.stop()は復元失敗後もteardownを続けてdetached HEADに取り残した - 対策: 横断的な制約(read-only等)は呼び出し経路に依存しないbackend層で強制し、facadeのガードは通知用に留める。GitHub/localで同じ概念を別フィールドに持つ場合、共通helperが両方を見ているか
state.review_modeで確認する。片側に既に存在する失敗時の方針(復元失敗→中断・維持)は、もう片側に同種の処理を足すとき必ず照合する - 再発 (PR #236, 2026-10-10): ローカルbackend(
local_sync)はcallbackを同期で呼び、その中で再描画まで済ませる。GitHub backendの非同期callbackを前提に「callback→vim.scheduleで下書き削除→直後にrefresh()」と書いた箇所は、ローカルでは削除前に再描画が走り✎draftが残った。同じ呼び出し元を両backendで共有するときは、callbackが同期か非同期かで後始末と再描画の順序が変わらないか確認し、再描画は後始末の後に置く - 該当箇所: lua/fude/comments/local_sync.lua, lua/fude/init.lua, lua/fude/local/session.lua, lua/fude/ui/comment_browser.lua
- 問題: commit scopeのcheckoutについてCopilotが7ラウンドにわたり同系統の穴を指摘した。(1) clean判定が初回進入時だけで、commit→commitや「戻る」方向では未検査だった。(2)
git statusが見ない状態(未保存バッファ、未送信のコメント入力、別tabの入力float、内側worktreeのバッファの誤判定)を見落としていた。(3) 復旧用pointerをcheckoutの後に書いていたため、その間のクラッシュで復帰不能だった。pointerの書き込み失敗を無視して進んでいた。復元後にstaleなcommit pointerが残り、意図的なdetachを誤認した - 対策: 外部状態を変える操作(checkout等)は、進入・遷移・復帰の全方向で同じ前提条件チェックを通す。チェック対象はgitの状態だけでなくNeovim側の状態(modifiedバッファ、入力UIの未送信テキスト)も列挙し、探索範囲(全tab、所有worktreeの最深一致)を明示する。復旧情報は副作用の前に永続化し、書けなければ中止する(write-ahead)。復帰に成功したら復旧情報を更新し、更新できなければ削除する(staleな復旧情報は無いより悪い)。唯一の例外は終了時(
VimLeavePre)で、detached HEADに取り残すよりは警告して強行する - 該当箇所: lua/fude/local/session.lua, lua/fude/ui.lua
- 問題: gh応答コールバックで
if err then vim.notify ... return endを先に書き、config.state ~= captured_stateのチェックをその後に置いていた。停止・再開後に届いた失敗応答がガードに到達せず、古いセッションのエラーが現行セッションの通知として表示された。成功経路だけを「古い応答を捨てる」対象と考えていた - 対策: 非同期コールバックの同一性ガードは、エラー分岐を含むあらゆる分岐より前、コールバックの先頭に置く。「成功時のstate更新を守る」だけでなく「失敗通知も古いセッションのもの」であることを前提にする。テストは
reset_state()後に成功応答と失敗応答の両方を流し、通知・on_done・stateのいずれも動かないことを確認する。既存の同種サイト(stack.luaのget_pr_stackコールバック、scope.luaのis_current_request)は先頭チェックなので、それに揃える - 該当箇所: lua/fude/files.lua
- 問題:
:FudeReviewStackSwitchはinit.stop()→gh pr checkout(非同期)→callbackでinit.start()という流れだったが、(1) checkout中にユーザーが別のレビューを開始できたため、callback時点のactiveだけ見る判定では新レビューが旧ブランチのstateのまま残り、start()が二重に走り得た。(2) 元ブランチへの復元checkoutが失敗してもchecktime→start()を続行していたため、HEADが中途半端な状態で別PRのレビューを開始し得た - 対策: 外部状態(HEAD)を非同期に書き換える処理の間は、競合する入口(
init.start()・local/session.start())を共有フラグで明示的に拒否する(単一の切替しか走らない設計ならgenerationではなくフラグで足りる)。復元に失敗したら処理を続行せず、レビューを停止したまま手動復旧の手順(checkoutすべきref)をERRORで案内する。「中断・維持」の方針は後続のHEAD操作(#207 commit scope)でも照合する - 該当箇所: lua/fude/stack.lua
- 問題:
git worktree list --porcelainのprunableエントリ(ディレクトリが消えたworktree)をパース時に捨てていたため、そのブランチは「どこにもcheckoutされていない」と判定されgh pr checkoutに進んだが、gitはmetadataが残る限りそのブランチを別worktreeでcheckout済みとして予約するためcheckoutは失敗した。「ディレクトリ不在ならstop前に拒否」という設計も、エントリを捨てた時点で到達不能になっていた - 対策: 外部ツールの一覧出力を「使えないものを除外」する形でパースするときは、除外した要素が後続の操作で別の失敗(予約・ロック等)を引き起こさないかを確認する。失敗するなら除外せず保持し、事前検査で明示的に拒否して復旧手順(
git worktree prune)を案内する - 該当箇所: lua/fude/diff.lua, lua/fude/stack.lua
- 問題: 同じ
created_atを持つコメントのタイブレークをid同士の文字列比較で行っていたため、999999999と1000000000のように桁数が変わる境界で後者が先に並んだ。ブラウザはこの順序を保持し、find_editable_commentが末尾から探すため、e/dが最新ではなく古いコメントを対象にした - 対策: gh APIのidは
numberで届くので、ソートキーに使うときは数値として比較する(文字列と数値が混在し得るなら両方数値のときだけ数値比較し、それ以外は文字列比較にフォールバックする)。同時刻・異なる桁数のidを並べる回帰テストを置き、順序だけでなくそれに依存する選択結果(編集対象)まで固定する - 該当箇所: lua/fude/comments/data.lua
- 問題: 「同一性ガードはコールバック先頭に置く」というルールの先例として
init.reloadを挙げたが、実際には各取得コールバックの更新条件内と完了バリア後で確認する構造で、ルールの参照先と説明が食い違っていた。captured_stateをgrepしてヒットしたファイルを、構造を読まずに先例として列挙していた - 対策: ルールの根拠として既存コードを名指しするときは、そのコードがルールの要件(位置・順序・条件)を本当に満たしているかを該当関数を読んで確認する。grepで「同じ識別子を使っている」ことと「同じ規則に従っている」ことは別で、満たさない例を挙げるとルール自体の信頼性を下げる
- 該当箇所: .claude/review-lessons.md
- 問題:
parse_name_statusのC(copy)分岐があることを根拠に「copied fileも旧パスと比較する」と書いたが、実際の入力元diff.get_name_statusはgit diff -Mでrenameしか検出しないため、local modeではcopyがaddedになり分岐に到達しなかった - 対策: パーサーの分岐を根拠に挙動を文書化するときは、呼び出し元のコマンドやAPIがその形式を実際に出力するか(gitのフラグ、APIのstatus値)まで遡って確認する。到達しない分岐はmode限定で書くか、記載から外す
- 該当箇所: lua/fude/local/session.lua, doc/fude.txt
- 問題: 複数行コメントの範囲強調を
update_inline_hint(CursorMovedで呼ばれ、同じbuffer/lineなら早期return)に載せたため、カーソルを動かさないままreloadや再anchorでcomment_mapが変わると、削除・移動された範囲の強調と開始マーカーが次のカーソル移動まで残った - 対策: カーソル位置をキーに「表示済みならskip」するextmarkを足すときは、そのデータを書き換える描画経路(
refresh_extmarks等)からも無効化・再構築する。他windowをnvim_win_callで描画する経路があるので、再構築は表示中のバッファに限り、カーソルのあるwindowから表示を移さない - 該当箇所: lua/fude/ui/extmarks.lua