From 1193f5291691b712941bddc9b0068931da66c910 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 03:23:02 +0000 Subject: [PATCH 1/6] =?UTF-8?q?Fix:=20cross-refactoring=20=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E6=A9=9F=E6=A4=9C=E8=A8=BC=E3=81=A7=E8=A6=8B=E3=81=A4?= =?UTF-8?q?=E3=81=8B=E3=81=A3=E3=81=9F=E4=B8=8D=E5=85=B7=E5=90=88=209=20?= =?UTF-8?q?=E4=BB=B6=E3=82=92=E4=BF=AE=E6=AD=A3=EF=BC=88v8.2.0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実機検証(PR #118)で収束ループが適用フェーズから先へ進めなくなった原因を直す。 - 取り消しが他項目のコミットと競合する問題を、範囲の巻き戻しと積み直しで解消 (分離できない位置関係のときはラウンド全件へ退避する) - 取り消し失敗を中断(終了コード 4)として扱い、「全件失敗」(2)と区別 - 項目ごとの判定を都度保存し、中断しても到達点を状態から読めるようにする - 取り消しへ着手する前に pending_push を立て、未検証の差分を公開したまま残さない - --scope を適用・修正の検証にも反映し、生成物の同期を進行側の責務へ分離 - 提案の結果ファイル名にラウンド番号を追加し、次ラウンドでの上書きを防ぐ - gemini の作業ディレクトリへ読み取り除外を無効にする設定を配置 - 提案プロンプトへ語彙の許容値を機械的に列挙 - init が参加 CLI の認証状態を確認し、未認証なら中断 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GSwBvT9CH8mKfgyFn2JWfS --- .claude-plugin/marketplace.json | 2 +- AGENTS.md | 2 +- CLAUDE.md | 10 +- README.md | 25 +- ...ssue-113-cross-refactoring-defect-fixes.md | 234 +++++++++ ...issue-113-cross-refactoring-fix-handoff.md | 16 +- plugins/ndf-claude/.claude-plugin/plugin.json | 4 +- .../skills/cross-refactoring/SKILL.md | 69 ++- .../docs/01-state-and-propose.md | 60 ++- .../docs/02-apply-and-review.md | 130 ++++- .../skills/cross-refactoring/prompts/apply.md | 5 + .../skills/cross-refactoring/prompts/fix.md | 3 + .../cross-refactoring/prompts/propose.md | 23 +- .../cross-refactoring/scripts/launch-cli.sh | 19 +- .../scripts/prepare-worktrees.sh | 35 ++ .../cross-refactoring/scripts/refactor.py | 465 ++++++++++++++++-- .../tests/test_abandon_items.py | 103 ++++ .../tests/test_drop_items_git.py | 221 +++++++++ .../cross-refactoring/tests/test_init.py | 94 ++++ .../tests/test_merge_apply.py | 241 +++++++-- .../tests/test_merge_proposals.py | 18 +- .../tests/test_prepare_worktrees.py | 32 ++ plugins/ndf-codex/.codex-plugin/plugin.json | 4 +- plugins/ndf-codex/README.md | 6 +- .../skills/cross-refactoring/SKILL.md | 69 ++- .../docs/01-state-and-propose.md | 60 ++- .../docs/02-apply-and-review.md | 130 ++++- .../skills/cross-refactoring/prompts/apply.md | 5 + .../skills/cross-refactoring/prompts/fix.md | 3 + .../cross-refactoring/prompts/propose.md | 23 +- .../cross-refactoring/scripts/launch-cli.sh | 19 +- .../scripts/prepare-worktrees.sh | 35 ++ .../cross-refactoring/scripts/refactor.py | 465 ++++++++++++++++-- .../tests/test_abandon_items.py | 103 ++++ .../tests/test_drop_items_git.py | 221 +++++++++ .../cross-refactoring/tests/test_init.py | 94 ++++ .../tests/test_merge_apply.py | 241 +++++++-- .../tests/test_merge_proposals.py | 18 +- .../tests/test_prepare_worktrees.py | 32 ++ plugins/ndf-kiro/README.md | 2 +- plugins/ndf-kiro/VERSION | 2 +- .../skills/cross-refactoring/SKILL.md | 69 ++- .../docs/01-state-and-propose.md | 60 ++- .../docs/02-apply-and-review.md | 130 ++++- .../skills/cross-refactoring/prompts/apply.md | 5 + .../skills/cross-refactoring/prompts/fix.md | 3 + .../cross-refactoring/prompts/propose.md | 23 +- .../cross-refactoring/scripts/launch-cli.sh | 19 +- .../scripts/prepare-worktrees.sh | 35 ++ .../cross-refactoring/scripts/refactor.py | 465 ++++++++++++++++-- .../tests/test_abandon_items.py | 103 ++++ .../tests/test_drop_items_git.py | 221 +++++++++ .../cross-refactoring/tests/test_init.py | 94 ++++ .../tests/test_merge_apply.py | 241 +++++++-- .../tests/test_merge_proposals.py | 18 +- .../tests/test_prepare_worktrees.py | 32 ++ .../skills/cross-refactoring/SKILL.md | 69 ++- .../docs/01-state-and-propose.md | 60 ++- .../docs/02-apply-and-review.md | 130 ++++- .../skills/cross-refactoring/prompts/apply.md | 5 + .../skills/cross-refactoring/prompts/fix.md | 3 + .../cross-refactoring/prompts/propose.md | 23 +- .../cross-refactoring/scripts/launch-cli.sh | 19 +- .../scripts/prepare-worktrees.sh | 35 ++ .../cross-refactoring/scripts/refactor.py | 465 ++++++++++++++++-- .../tests/test_abandon_items.py | 103 ++++ .../tests/test_drop_items_git.py | 221 +++++++++ .../cross-refactoring/tests/test_init.py | 94 ++++ .../tests/test_merge_apply.py | 241 +++++++-- .../tests/test_merge_proposals.py | 18 +- .../tests/test_prepare_worktrees.py | 32 ++ 71 files changed, 5973 insertions(+), 406 deletions(-) create mode 100644 issues/issue-113-cross-refactoring-defect-fixes.md create mode 100644 plugins/ndf-claude/skills/cross-refactoring/tests/test_drop_items_git.py create mode 100644 plugins/ndf-codex/skills/cross-refactoring/tests/test_drop_items_git.py create mode 100644 plugins/ndf-kiro/skills/cross-refactoring/tests/test_drop_items_git.py create mode 100644 plugins/ndf-shared/skills/cross-refactoring/tests/test_drop_items_git.py diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a8147bd5..7ca19470 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf-claude", - "description": "Claude Code plugin (v8.1.0): 8 specialized agents and 27 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/Gemini), transcript retention guard, and optional Slack notifications." + "description": "Claude Code plugin (v8.2.0): 8 specialized agents and 27 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/Gemini), transcript retention guard, and optional Slack notifications." }, { "name": "playwright-kit", diff --git a/AGENTS.md b/AGENTS.md index bb77bd42..c951d41d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -77,7 +77,7 @@ ai-plugins/ ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v8.1.0)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf-claude` / `plugins/ndf-codex` / `plugins/ndf-kiro` に分離しています。 +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v8.2.0)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf-claude` / `plugins/ndf-codex` / `plugins/ndf-kiro` に分離しています。 - 共通編集元は `plugins/ndf-shared/` - Claude Code版は 8個の専門サブエージェント、公開Skills、SessionStart/Stopフックを提供 - Codex版は Codex向け公開Skillsと任意Slack通知hookを提供 diff --git a/CLAUDE.md b/CLAUDE.md index b44d4700..caf75cc6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ skills/ → 実行可能なワークフロー 詳細は `docs/specifications/ndf-knowledge-and-kiro.md` を参照。 -## NDF v8.1.0 の Skill 構成 +## NDF v8.2.0 の Skill 構成 Skill は 31 個で、配布は `plugins/ndf-shared/manifests/` が唯一の基準(Claude Code 27 / Codex 25 / Kiro 26)。ブラウザ自動テストの 4 個は `playwright-kit` プラグインへ分離した(`plugins/playwright-kit-shared/`)。frontmatter の書き方は `plugins/ndf-shared/skills/README.md` の規約に従い、`python3 scripts/check-skill-frontmatter.py` で検査する。利用実績と維持・統合・削除の判定は `docs/specifications/ndf-skill-inventory.md` に記録する。 @@ -41,6 +41,8 @@ v8.0.0 で `safe-refactoring` を `refactoring` へ改名し、分岐・反復 v8.1.0 で `cross-refactoring` を追加した。あわせて収束ループの共通層を `plugins/ndf-shared/skills/cross-review/scripts/lib/` へ切り出し、`monitor.py` は同ディレクトリへ移設して既存パスをシムにした。`cross-review` の挙動と既存テストは変えていない。 +v8.2.0 で `cross-refactoring` の実機検証(PR #118)で見つかった 9 件の不具合を直した。`cross-review` と共通層は変更していない。詳細は `issues/issue-113-cross-refactoring-defect-fixes.md`。 + v6.0.0 の対応表(`review` → `pr-review`)は予告どおり削除済み。v6.0.0 以前から移行する場合は v6.1.0 の `ndf-policies` を参照する。 ## cross-refactoring @@ -52,10 +54,12 @@ v6.0.0 の対応表(`review` → `pr-review`)は予告どおり削除済み /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=opus-5 ``` -- `--scope` は必須。提案が発散して PR が肥大するのを防ぐ +- `--scope` は必須。提案が発散して PR が肥大するのを防ぐ。**検証にも効く**ので、現状固定テストの置き場所も含める - ホストと同じランタイムが適用担当になる場合も、サブエージェントではなく **CLI プロセス**として起動する - モデルを比べるなら `--model kiro=` を必ず指定する(既定 `auto` は実際に動いたモデルを取得できない) -- 収束しない改善項目は **項目単位で取り消す**。合意済みの項目は PR に残る +- 収束しない改善項目は **項目単位で取り消す**。合意済みの項目は PR に残る。ただし同一ファイルの隣接行を触る項目どうしは git だけでは分離できないため、そのラウンドは全件取り消しへ退避する +- 生成物・配布物の同期は **進行側の責務**。実装担当にはさせない(範囲外の変更になる) +- `init` が参加 CLI の認証状態を確認する。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` ## cross-review diff --git a/README.md b/README.md index 8ffdbcdf..18777239 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Claude Code / Codex / Kiro CLI向けのスキル・MCP設定を共有するた このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Codex / Kiro CLI)の導入を加速するための事前設定されたプラグインを提供します。 -**NDFプラグイン v8.1.0** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI へ配布されるランタイム別プラグインです。共通ソースは `plugins/ndf-shared/` に集約し、利用者が install する配布物は `plugins/ndf-claude/` / `plugins/ndf-codex/` / `plugins/ndf-kiro/` に分かれています。 +**NDFプラグイン v8.2.0** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI へ配布されるランタイム別プラグインです。共通ソースは `plugins/ndf-shared/` に集約し、利用者が install する配布物は `plugins/ndf-claude/` / `plugins/ndf-codex/` / `plugins/ndf-kiro/` に分かれています。 - **公開Skills**: Claude Code向け core 27個、Kiro向け core 26個、Codex向け core 25個に分離。 - **元Skills(30個)**: @@ -102,9 +102,30 @@ kiro-cli chat --agent ndf | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 8.1.0 | Claude Code / Codex / Kiro CLI 向けに runtime 別配布物を提供する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 27個、Kiro向け core 26個、Codex向け core 25個)、Claude SessionStart/Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [Claude](./plugins/ndf-claude/README.md) / [Codex](./plugins/ndf-codex/README.md) / [Kiro](./plugins/ndf-kiro/README.md) | +| **ndf** | 8.2.0 | Claude Code / Codex / Kiro CLI 向けに runtime 別配布物を提供する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 27個、Kiro向け core 26個、Codex向け core 25個)、Claude SessionStart/Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [Claude](./plugins/ndf-claude/README.md) / [Codex](./plugins/ndf-codex/README.md) / [Kiro](./plugins/ndf-kiro/README.md) | | **playwright-kit** | 1.0.0 | Playwright による E2E テストの計画・実装・証跡管理を提供するプラグイン。ページ役割からのテスト計画、動画 / trace 付きスクリプト実装、レポート生成と Drive 保管、playwright_kit ランタイム(init、a11y / CWV スキャン)の 4 Skill。NDF v7.0.0 で分離。 | [Claude](./plugins/playwright-kit-claude/README.md) | +### NDF v8.2.0 の主な変更 + +**`/ndf:cross-refactoring` を実機検証で見つかった 9 件の不具合について修正しました。** +提案フェーズは設計どおり動いていましたが、適用結果の検証で失敗した項目を取り消す経路が +破綻し、進行を続行できない状態でした。 + +| 直したこと | 変更 | +| --- | --- | +| 取り消しが他項目のコミットと競合する | 範囲を新しい順に全て戻し、残す項目を積み直す。分離できない位置関係のときはラウンド全件へ退避する | +| 取り消し失敗を握り潰して進行する | 中断を**終了コード 4** で表し、「全件失敗」(2)と区別する | +| 適用結果が状態に残らない | 項目ごとの判定を**その都度**保存する(`rounds[].apply_progress`) | +| 未検証の変更が公開されたまま残る | 取り消しへ着手する**前**に `pending_push` を立て、次の実行で再送信する | +| 範囲外の変更を検証しない | `--scope` を適用・修正の検証にも効かせる。生成物の同期は**進行側の責務**へ分離 | +| 提案の記録が次ラウンドで上書きされる | 提案の結果ファイル名にもラウンド番号を入れる | +| gemini が配置した手順書を読めない | 作業ディレクトリへ読み取り除外を無効にする設定を置く | +| 語彙の許容値をプロンプトが列挙しない | 検証側が持つ語彙集合をプロンプトへ機械的に列挙する | +| 初期化が CLI の認証を確認しない | `init` が参加 CLI の認証状態を確認し、未認証なら中断する | + +**互換性**: 提案の結果ファイル名が `<ランタイム>-propose-rf-r<ラウンド>-result.json` へ +変わります。`--scope` には**現状固定テストの置き場所も含めてください**(検証に効くため)。 + ### NDF v8.1.0 の主な変更 **多ランタイム・リファクタリング収束ループ `/ndf:cross-refactoring` を追加しました。** diff --git a/issues/issue-113-cross-refactoring-defect-fixes.md b/issues/issue-113-cross-refactoring-defect-fixes.md new file mode 100644 index 00000000..0600c6d8 --- /dev/null +++ b/issues/issue-113-cross-refactoring-defect-fixes.md @@ -0,0 +1,234 @@ +# issue-113: cross-refactoring 実機検証で見つかった不具合 9 件の修正 + +## 関連リンク + +- [issue-113-cross-refactoring-trial-report.md](issue-113-cross-refactoring-trial-report.md) — 不具合の内容とエビデンス +- [issue-113-cross-refactoring-fix-handoff.md](issue-113-cross-refactoring-fix-handoff.md) — 着手順と決めるべきこと +- 対象 PR: #118(実機検証に使った Draft) + +## モード + +`architecture`。`refactor.py` のサブコマンド境界(検証・取り消し・状態記録)と結果ファイルの +命名規約という公開インタフェースを変更し、`cross-refactoring` / `cross-review` 共通層 / +プロンプト / 手順書の複数モジュールにまたがるため。 + +## 目的と非目的 + +達成したい状態: + +- 適用結果の検証で失敗した項目を、**他の項目のコミットと競合せずに**取り消せる +- 取り消しに失敗したときは進行を**止める**(握り潰して次ラウンドへ進まない) +- 中断しても、どこまで到達したかを状態ファイルから読める +- 検証を通っていない変更が Pull Request に残らない +- `--scope` の指定が適用結果の検証に反映される +- 提案・レビューの参加者が、認証切れで黙って脱落しない + +やらないこと: + +- レビューフェーズ以降の実機検証(本 PR では単体テストまで。実機は別途) +- 語彙の日本語表記を受理する正規化(**許容値の列挙**までに留める) +- 生成物同期の自動実行(進行側の責務として**手順に明記**するだけ) + +## 前提 + +- 前提 1: 適用フェーズ時点では `apply_base_sha..HEAD` の全コミットが + いずれかの改善項目に割り当て済みである(未割当があれば `merge-apply` が + ラウンドごと取り消すため、項目単位の取り消し経路には到達しない) +- 前提 2: `git push --force` は使わない。履歴の書き換えではなく + **revert + cherry-pick の積み直し**で前進のみを行う + +## 受け入れ条件 + +- [ ] 1. 取り消し対象より新しい別項目のコミットがあっても、**変更が独立していれば** + 項目単位で取り消せる(`test_drop_older_item_keeps_the_newer_one`) +- [ ] 2. 積み直しが競合したときは、着手前の状態まで戻してラウンド全件を取り消し、 + 半端な履歴を残さない(`test_adjacent_changes_fall_back_to_the_whole_round`) +- [ ] 3. 取り消しに失敗したら終了コード 4 で中断する。進行スクリプトは + 終了コード 2(全件失敗)と 4(中断)を区別する +- [ ] 4. 検証の途中で中断しても、そこまでの判定が状態ファイルへ残る + (`items[].status` と `rounds[].apply.progress`) +- [ ] 5. 取り消しの push が完了するまで `pending_push` が立ち、 + 次の実行が処理済み判定より先に再送信する(取り消し着手**前**に立てる) +- [ ] 6. `target_scope` の外を触ったコミットを含む項目は失敗になる + (`test_out_of_scope_commit_fails_the_item`) +- [ ] 7. 提案の結果ファイル名にラウンド番号が入り、2 巡目が 1 巡目を上書きしない +- [ ] 8. gemini の作業ディレクトリで、配置した手順書を読み取れる設定が置かれる +- [ ] 9. 提案プロンプトに `smell` / `technique` / `severity` の許容値が列挙される +- [ ] 10. 初期化が参加ランタイムの認証状態を確認し、未認証なら失敗する +- [ ] 11. 既存 387 件のテストが退行しない + +## 代替案と採否 + +### 取り消しの単位 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| A | 項目単位を維持し、**範囲を新しい順に全て戻してから残す項目を積み直す** | 採用 | 「合意済みの項目は残す」という設計方針を保てる。範囲全体の巻き戻しは常に競合しない | +| B | ラウンド単位へ変更する | 不採用 | 実装は単純だが、1 件の失敗で合意済みの項目まで捨てることになる | +| C | 失敗項目のコミットだけを新しい順にまとめて戻す | 不採用 | 取り消し対象より新しい**別項目**のコミットが同じ箇所を触ると必ず競合する(不具合 1 の再現) | + +案 A で積み直しが競合した場合だけ、案 B(ラウンド全件の取り消し)へ**退避**する。 +これにより最悪でも決定的な状態に落ち、半端な履歴を残さない。 + +#### 実装して分かったこと(案 A の限界) + +実機の git で確かめたところ、**同一ファイルの隣接行を触る項目どうしは積み直しでも +競合する**(`tests/test_drop_items_git.py`)。取り消した側の行が消えると、残す側の +パッチが前提にしている文脈も消えるためで、git だけでは決められない。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル | 項目単位 | +| 同一ファイルの離れた行 | 項目単位 | +| 同一ファイルの隣接行 | ラウンド全件へ退避 | + +実測(PR #118)では採用 5 件のうち 4 件が同一ファイルの隣接領域を変更していたので、 +**この構成では退避が普通に起こる**。それでも案 A を採る理由は 2 つある。 + +- 案 C(現状)は**進行が止まる**。案 A は最悪でも決定的な状態に落ちて進行を続けられる +- 変更が独立していれば項目単位が保たれる。範囲や採用件数を絞れば独立させられる + +### 配布物同期の責務 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| A | 進行側(ホスト)が収束後にまとめて生成する | 採用 | 範囲の指定と整合する。実装担当の差分が範囲内に収まり、差分予算も現実的になる | +| B | 実装担当に同期させる | 不採用 | 範囲外の変更が生まれ、差分が 4 倍に膨らんだ(不具合 5 の実測) | + +## ドメイン用語 + +| 用語 | 意味 | +| --- | --- | +| 積み直し | 範囲を全て取り消したあと、残す項目のコミットを `git cherry-pick` で載せ直すこと | +| 退避 | 積み直しが競合したときに、ラウンド全件の取り消しへ切り替えること | +| 中断 | 進行を止めること。終了コード 4 で表す(2 の「全件失敗」と区別する) | + +## 不変条件 + +- Pull Request に残るのは、**検証を通ったコミットだけ**である +- `git push --force` と `--no-verify` は使わない +- 取り消しは冪等である。叩き直しても二重に取り消さない +- 状態ファイルの `items[].commits` は、**現在の履歴に実在する SHA** を指す + (積み直しで SHA が変わったら更新する) + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| `refactor.py` の終了コード | 中断を 4 として追加 | 追加のみ。0 / 2 / 3 の意味は変えない | +| 提案の結果ファイル名 | `-r<ラウンド>` を追加 | 破る。進行スクリプトと `--stem-template` を同時に変更する | +| 状態ファイル | `vocabulary` / `auth` / `apply.progress` を追加 | 追加のみ。欠けていても読める | +| `--scope` の意味 | 検証にも使う | 破る(これまで検証に反映されていなかった)。手順書に明記する | + +## 修正対象 + +``` +plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +plugins/ndf-shared/skills/cross-refactoring/scripts/prepare-worktrees.sh +plugins/ndf-shared/skills/cross-refactoring/scripts/launch-cli.sh +plugins/ndf-shared/skills/cross-refactoring/prompts/propose.md +plugins/ndf-shared/skills/cross-refactoring/prompts/apply.md +plugins/ndf-shared/skills/cross-refactoring/prompts/fix.md +plugins/ndf-shared/skills/cross-refactoring/SKILL.md +plugins/ndf-shared/skills/cross-refactoring/docs/01-state-and-propose.md +plugins/ndf-shared/skills/cross-refactoring/docs/02-apply-and-review.md +plugins/ndf-shared/skills/cross-refactoring/tests/ +plugins/ndf-{claude,codex,kiro}/skills/... # 配布物(生成) +``` + +## タスク分解 + +### Task 1: 取り消しを「巻き戻して積み直す」形に変える + +- **対象ファイル:** `scripts/refactor.py`、`tests/test_abandon_items.py`、`tests/test_merge_apply.py` +- **変更内容:** `_drop_items()` を追加する。範囲を新しい順に全て `git revert` し、 + 残す項目のコミットを古い順に `git cherry-pick` で積み直す。積み直しが競合したら + 着手前 HEAD へ戻し、ラウンド全件の取り消しへ退避する。 + `cmd_merge_apply` と `cmd_abandon_items` を `_drop_items()` 経由に置き換える +- **満たす受け入れ条件:** 1, 2 +- **進め方:** 競合する履歴を模す失敗テスト → 実装 → 既存の取り消しテストを新形へ移す + +### Task 2: 中断と進捗記録を分ける + +- **対象ファイル:** `scripts/refactor.py`、`SKILL.md`、`docs/02-apply-and-review.md` +- **変更内容:** 中断を終了コード 4 に統一する(`die` の既定値)。`cmd_merge_apply` は + 項目ごとの判定を**その都度**状態ファイルへ保存する。取り消しへ着手する**前**に + `pending_push` を立てる。進行スクリプトは 2 と 4 を区別し、4 では `exit` する +- **満たす受け入れ条件:** 3, 4, 5 +- **進め方:** 中断時の状態を確かめる失敗テスト → 実装 → 手順書の更新 + +### Task 3: 範囲外のファイル変更を検証で捕まえる + +- **対象ファイル:** `scripts/refactor.py`、`prompts/apply.md`、`prompts/fix.md`、`docs/02-apply-and-review.md` +- **変更内容:** `commit_files()` を追加し、`collect_commit_facts()` の事実へ `files` を含める。 + `verify_apply_item()` / `verify_fix_commit()` が `target_scope` の外を触ったコミットを + 失敗にする。プロンプトへ「生成物の同期はしない」を明記し、手順書へ進行側の責務として書く +- **満たす受け入れ条件:** 6 +- **進め方:** 範囲外コミットを含む事実を渡す失敗テスト → 実装 → 文書追従 + +### Task 4: 提案の結果ファイルをラウンドごとに分ける + +- **対象ファイル:** `scripts/refactor.py`、`scripts/launch-cli.sh`、`SKILL.md`、`docs/01-state-and-propose.md` +- **変更内容:** `stem_for()` の `propose` にラウンド番号を入れ、呼び出し側と + `--stem-template` を揃える +- **満たす受け入れ条件:** 7 +- **進め方:** `stem_for()` の失敗テスト → 実装 → 進行スクリプトと手順書の更新 + +### Task 5: gemini が手順書を読めるようにする + +- **対象ファイル:** `scripts/prepare-worktrees.sh`、`tests/test_prepare_worktrees.py`、`docs/01-state-and-propose.md` +- **変更内容:** gemini の作業ディレクトリへ `.gemini/settings.json` を置き、 + 読み取り側の除外を無効にする。版差に備えて `context.fileFiltering` と + `fileFiltering` の両方を書く。`.gemini/` ごと差分に出さない +- **満たす受け入れ条件:** 8 +- **進め方:** 配置内容を確かめる失敗テスト → 実装 + +### Task 6: 語彙の許容値をプロンプトへ列挙する + +- **対象ファイル:** `scripts/refactor.py`、`scripts/launch-cli.sh`、`prompts/propose.md`、`tests/test_init.py` +- **変更内容:** `init` が `SMELLS` / `TECHNIQUES` / 重要度を状態ファイルの `vocabulary` へ書き、 + `launch-cli.sh` が jq で読んでプロンプトへ差し込む +- **満たす受け入れ条件:** 9 +- **進め方:** `vocabulary` の記録を確かめる失敗テスト → 実装 → 雛形の更新 + +### Task 7: 初期化で認証状態を確認する + +- **対象ファイル:** `scripts/refactor.py`、`tests/test_init.py`、`SKILL.md`、`docs/01-state-and-propose.md` +- **変更内容:** 参加ランタイム(提案・レビューの母集合 ∪ 適用の母集合 − ホスト)ごとに + 認証確認コマンドを実行し、失敗したら初期化ごと中断する。結果を状態ファイルへ残す +- **満たす受け入れ条件:** 10 +- **進め方:** 未認証を模す失敗テスト → 実装 → 前提の記載を更新 + +### Task 8: 配布物を生成する + +- **対象ファイル:** `plugins/ndf-{claude,codex,kiro}/` +- **変更内容:** `bash scripts/build-runtime-plugins.sh` を実行する +- **満たす受け入れ条件:** 11 +- **進め方:** 生成後に差分を確認する(テスト駆動の対象外。生成物のため) + +## 影響範囲 + +- `cross-refactoring` の全フェーズ(初期化・提案・適用・レビュー・修正・見送り) +- `cross-review` 共通層は**変更しない**(`assignment` / `statefile` / `monitor` はそのまま) +- 配布物 3 系統 + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 積み直しの競合が頻発し、実質ラウンド単位になる | 退避したことを状態と報告へ残し、頻度を実機で測れるようにする | +| 認証確認コマンドが CLI の版で変わる | ランタイムごとに 1 箇所へ表として置き、失敗理由に実行したコマンドを出す | +| 範囲検査が厳しすぎて正当な変更まで落ちる | 範囲の判定は前方一致のみ。除外規則は作らない(設定が増えると検証が骨抜きになる) | + +## 切り戻し手順 + +- コード変更のみでデータ移行は無い。ブランチごと戻せる +- 状態ファイルへ追加した項目は欠けていても読めるため、旧版の状態ファイルとも互換 + +## 完了の定義 + +- [ ] 受け入れ条件 1〜11 をすべて満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run --with pytest python -m pytest <2 つの tests ディレクトリ> -q` が全件成功 +- [ ] `python3 scripts/check-skill-frontmatter.py` が成功 +- [ ] `claude plugin validate` が成功 +- [ ] `/ndf:cross-review` が収束 diff --git a/issues/issue-113-cross-refactoring-fix-handoff.md b/issues/issue-113-cross-refactoring-fix-handoff.md index 1c5ef2ff..0ec6b25c 100644 --- a/issues/issue-113-cross-refactoring-fix-handoff.md +++ b/issues/issue-113-cross-refactoring-fix-handoff.md @@ -1,5 +1,16 @@ # cross-refactoring 不具合修正の引継ぎ +> **対応済み(NDF v8.2.0)。** 9 件すべてを修正した。設計判断と結果は +> [issue-113-cross-refactoring-defect-fixes.md](issue-113-cross-refactoring-defect-fixes.md) +> にある。以下は着手時点のメモとして残す。着手前と変わった点は 2 つ。 +> +> - **不具合 7 の回避は編集元へ反映済み**(`prepare-worktrees.sh`)。 +> プラグインキャッシュへの手当ては不要になった +> - **取り消しは項目単位を保てないことがある。** 同一ファイルの隣接行を触る項目どうしは +> git だけでは分離できず、そのラウンドは全件取り消しへ退避する(実測で確認) +> +> 「未検証の範囲」(レビューフェーズ以降)は**依然として未検証**である。 + 実機検証で見つかった 9 件を修正するための作業メモ。 不具合の内容・エビデンス・修正の方向は [issue-113-cross-refactoring-trial-report.md](issue-113-cross-refactoring-trial-report.md) にある。 @@ -72,16 +83,17 @@ uv run --with pytest python -m pytest \ plugins/ndf-shared/skills/cross-refactoring/tests \ plugins/ndf-shared/skills/cross-review/tests -q -# 実機 +# 実機(`--scope` には現状固定テストの置き場所も含める。範囲は検証にも効く) /ndf:cross-refactoring \ --scope plugins/ndf-shared/skills/cross-refactoring/scripts \ + plugins/ndf-shared/skills/cross-refactoring/tests \ plugins/ndf-shared/skills/cross-review/scripts/lib \ --baseline-test "<上のテストコマンド>" ``` 実行前に確認すること。 -- `kiro-cli whoami` が認証済みを返す(不具合 9 が直るまでは手で確認する) +- 認証は `init` が確認する(不具合 9 は対応済み)。手で確認する必要は無くなった - 進行を駆動する作業ディレクトリが対象ブランチを掴んでいない (同じブランチを 2 か所へ展開できないため初期化に失敗する) diff --git a/plugins/ndf-claude/.claude-plugin/plugin.json b/plugins/ndf-claude/.claude-plugin/plugin.json index 5b4bbb1e..0952ed7a 100644 --- a/plugins/ndf-claude/.claude-plugin/plugin.json +++ b/plugins/ndf-claude/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "8.1.0", - "description": "Claude Code plugin (v8.1.0): 8 specialized agents and 27 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/Gemini), transcript retention guard, and optional Slack notifications.", + "version": "8.2.0", + "description": "Claude Code plugin (v8.2.0): 8 specialized agents and 27 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/Gemini), transcript retention guard, and optional Slack notifications.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf-claude/skills/cross-refactoring/SKILL.md b/plugins/ndf-claude/skills/cross-refactoring/SKILL.md index 0cd96bc7..bd01328d 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf-claude/skills/cross-refactoring/SKILL.md @@ -37,7 +37,8 @@ allowed-tools: | 役割の分離 | 提案・レビューは**ホストを除く 3 者**、適用は**gemini を除く 3 者**。両者は重なるが一致しない | | レビューの単位 | **提案ラウンドの差分全体**に対して 1 回。項目ごとに回すと CLI 起動回数が採用件数に比例して膨らむ | | 収束しない項目 | **捨てる。** リファクタリングは任意の作業なので、揉める提案を Pull Request に残さない | -| 取り消しの単位 | **改善項目ごと。** 合意済みの項目は残す。そのために 1 手 1 コミットを機械検証する | +| 取り消しの単位 | **改善項目ごと(独立している範囲で)。** 範囲を新しい順に全て戻し、残す項目を積み直す。同一ファイルの隣接行を触る項目どうしは git だけでは分離できないため、そのときは**ラウンド全件へ退避する** | +| 範囲の扱い | `--scope` は**検証にも効く**。範囲外を触ったコミットを含む項目は失敗になる。生成物の同期は進行側が収束後にまとめて行う | | 検証の情報源 | **git と実際のテスト実行。** 結果ファイルの申告は検証に使わない(書き換えるだけで通る検査にしない) | | 投稿 | **AI 自身が `gh api` で投稿する。** ホストの作業文脈に差分やレビュー本文を載せない | | 状態の永続化 | `/.cross_refactoring/cross-refactoring-rf<番号>-state.json` に集約。中断・再開可能 | @@ -48,7 +49,7 @@ allowed-tools: | 引数 | 意味 | 既定 | | --- | --- | --- | | `[PR番号]` | 対象の Pull Request | 必須 | -| `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須** | 必須 | +| `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須。** 検証にも効くので、現状固定テストの置き場所も含める | 必須 | | `--host claude\|codex\|kiro` | ホストの明示指定。未指定時は環境変数から推定 | 推定 | | `--model RT=MODEL` | ランタイムごとのモデル。繰り返し指定できる | CLI の既定 | | `--baseline-test CMD` | 着手前と各コミットで実行するテスト。**振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須** | 必須 | @@ -59,7 +60,7 @@ allowed-tools: | `--test-timeout SEC` | テスト 1 回あたりの上限秒数。超えたら失敗として扱う | `900` | ```text -/ndf:cross-refactoring 130 --scope src/services --baseline-test "pytest -q" +/ndf:cross-refactoring 130 --scope src/services tests/services --baseline-test "pytest -q" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=opus-5 /ndf:cross-refactoring 130 --scope src --host codex --max-outer-rounds 1 ``` @@ -88,6 +89,20 @@ allowed-tools: ## 前提 - `gh` CLI が認証済みで、`jq` と `uv`(または Python 3.10 以上)が使える +- 参加する CLI が**すべてログイン済み**である。`init` が認証状態を確認し、1 つでも + 未認証なら中断する(未認証の CLI は起動から 15 秒で終わり、結果を残さないまま + 担当から脱落するため、確認しないと参加者が欠けた構成のまま進行する) + + | ランタイム | 確認コマンド | + | --- | --- | + | claude | `claude auth status` | + | codex | `codex login status` | + | gemini | `gemini --skip-trust -p ping --output-format text` | + | kiro | `kiro-cli whoami` | + + 確認コマンドは CLI の版で変わりうる。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` で + 飛ばせる(飛ばしたことは出力に残る) + - ホストごとに次の CLI が使える(不足していると初期化時に失敗する) | ホスト | 必要な CLI | @@ -139,7 +154,19 @@ PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" SCRIPTS="$PLUGIN_ROOT/skills/cross-refactoring/scripts" LIB="$PLUGIN_ROOT/skills/cross-review/scripts/lib" -eval "$("$SCRIPTS/refactor.py" init "$PR" --scope $SCOPE \ +# **中断(終了コード 4)は握り潰さない。** 取り消しに失敗した状態を「全件失敗」と +# 同じ扱いにすると、検証を通っていない変更を Pull Request に残したまま次の提案が +# 始まる。判定に使う終了コードだけを呼び出し側へ返し、それ以外は進行ごと止める。 +rf() { + "$SCRIPTS/refactor.py" "$@"; local rc=$? + if [ $rc -eq 4 ]; then + echo "❌ cross-refactoring を中断しました(refactor.py $1)" >&2 + exit 4 + fi + return $rc +} + +eval "$(rf init "$PR" --scope $SCOPE \ --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS)" @@ -147,18 +174,18 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" "$SCRIPTS/prepare-worktrees.sh" "$ID" while :; do # 提案ラウンドの繰り返し - eval "$("$SCRIPTS/refactor.py" start-round "$ID")" || break + eval "$(rf start-round "$ID")" || break # 終了コード 1 = 繰り返し終了 for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ - --stem-template '{agent}-propose-rf{id}' - "$SCRIPTS/refactor.py" merge-proposals "$ID" || break # 終了コード 2 = 採用 0 件 + --stem-template "{agent}-propose-rf{id}-r$ROUND" + rf merge-proposals "$ID" || break # 終了コード 2 = 採用 0 件 "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-apply-r$ROUND" --timeout 3600 - "$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" || continue # 全件失敗 + rf merge-apply "$ID" "$ROUND" || continue # 終了コード 2 = 全件失敗 # 適用後の状態をレビュー担当へ見せるため、読み取り用を同期する "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" @@ -169,23 +196,37 @@ while :; do # 提案ラウンドの繰り返 done "$LIB/monitor.py" "$ID" --agents "$REVIEWERS_CSV" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-review-r$ROUND" - "$SCRIPTS/refactor.py" judge-review "$ID" "$ROUND"; rc=$? + rf judge-review "$ID" "$ROUND"; rc=$? [ $rc -eq 0 ] && break # 2 者とも承認 [ $rc -eq 3 ] && continue # 形式不正 — 差し戻して再レビュー - if "$SCRIPTS/refactor.py" should-abandon "$ID" "$ROUND"; then - "$SCRIPTS/refactor.py" abandon-items "$ID" "$ROUND"; break + if rf should-abandon "$ID" "$ROUND"; then + rf abandon-items "$ID" "$ROUND"; break fi "$SCRIPTS/launch-cli.sh" "$IMPL" fix "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-fix-r$ROUND" - "$SCRIPTS/refactor.py" merge-fix "$ID" "$ROUND" + rf merge-fix "$ID" "$ROUND" # 修正後の状態を再レビューさせる。同期しないと古い差分を評価してしまう "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" done - "$SCRIPTS/refactor.py" advance "$ID" || break + rf advance "$ID" || break done + +# 収束後にまとめて生成物を同期する(**進行側の責務**)。編集元から配布物を生成する +# 規約を持つリポジトリでは、実装担当に同期させると範囲外の変更が生まれる。 +# 同期が要るなら、ここで生成してから Step 7 の最終ゲートへ渡す。 ``` +### 終了コード + +| コード | 意味 | 進行 | +| --- | --- | --- | +| 0 | 正常 | 続ける | +| 1 | 繰り返しの終了(`start-round` / `advance`) | 抜ける | +| 2 | 判定の結果(採用 0 件 / 全件失敗 / 変更要求 など) | 各コマンドの表に従う | +| 3 | レビュー結果の形式不正 | 差し戻して再レビュー | +| **4** | **中断**(取り消しの失敗、認証切れ、範囲を確定できないなど) | **進行ごと止める** | + 続けて **Step 7** で `/ndf:cross-review ` を実行する。レビューはラウンド単位なので、 **ラウンドを跨いだ整合はここで見る**。収束したら Draft を解除し、 `refactor.py report "$ID" --metrics` の出力を報告する。 @@ -199,6 +240,8 @@ done | ホストのサブエージェントで適用する | ホストの作業文脈に差分が載り、実装者とレビュー担当の独立性が崩れる | | `launch-cli.sh` に「ホストなら起動しない」分岐を入れる | ホストは適用担当として起動しうる。分岐はランタイム名だけで行う | | `--scope` を省く | 提案が発散し、Pull Request が肥大する | +| 実装担当に生成物を同期させる | 範囲外の変更が生まれ、差分予算を超える。同期は進行側が収束後にまとめて行う | +| 取り消しの失敗を「全件失敗」として次のラウンドへ進む | 検証を通っていない変更が Pull Request に残る。終了コード 4 は必ず進行ごと止める | | `--dry-run` の出力を実行結果と混同する | 確認用なので git も状態ファイルも触らない。進行は 1 歩も進まない | | 複数の改善項目を 1 コミットにまとめる | 取り消し範囲が項目単位で決まらなくなる。適用結果の検証で失敗になる | | 結果ファイルの申告を検証の材料にする | 実装担当は報告する側。JSON を書き換えるだけで通る検査は機械検証ではない | diff --git a/plugins/ndf-claude/skills/cross-refactoring/docs/01-state-and-propose.md b/plugins/ndf-claude/skills/cross-refactoring/docs/01-state-and-propose.md index bfbb6735..487a9b4c 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/docs/01-state-and-propose.md +++ b/plugins/ndf-claude/skills/cross-refactoring/docs/01-state-and-propose.md @@ -37,7 +37,16 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 使うと古い HEAD に対して提案・適用してしまう。早送りできない(履歴が分かれた) ときは中断する。`git fetch` に失敗したときも中断する(古い `origin/` へ 早送りして「同期したつもり」になるのを防ぐ) -5. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 +5. **認証状態の確認** — 参加する CLI を 1 つずつ確認し、未認証なら**初期化ごと中断する** + (終了コード 4)。存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、 + 結果ファイルを残さないまま担当から脱落するが、それでも初期化は成功として扱われるため、 + **参加者が 1 人欠けた構成のまま最後まで進んでしまう**(実測)。作業ディレクトリを + 作る前に確認する。確認コマンドは CLI の版で変わりうるので `NDF_SKIP_AUTH_CHECK=1` + で飛ばせるが、飛ばしたことは必ず出力へ残す +6. **語彙の受け渡し** — 検証側が持つスメル・手法・重要度の集合を状態ファイルの + `vocabulary` へ書く。提案プロンプトはここから**許容値をそのまま列挙する**。 + 定義を 1 箇所に保ったまま、読ませ方の不確実性を減らすためである +7. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 壊れた状態から始めると、壊したのか元から壊れていたのか区別できない。 この引数は**必須**である。振る舞いが変わっていないことを示す手段が無い書き換えは、 `refactoring` Skill の定義からして構造改善ではない @@ -121,6 +130,30 @@ gemini は NDF の配布先ではないため「標準の配置先」を持た **対象リポジトリ本体を書き換える**ことになり、「配置は作業ディレクトリの中だけで完結 させる」という前提を破る。 +#### gemini は除外を読み取りにも適用する + +全件無視をそのまま効かせると、**gemini は配置した手順書を 1 つも開けない**。 + +```text +Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +is ignored by configured ignore patterns. +``` + +手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と +書いている前提が崩れるため、gemini の作業ディレクトリへ +`.gemini/settings.json` を置き、**読み取り側の除外だけを無効にする**。 + +```json +{ + "context": { "fileFiltering": { "respectGitIgnore": false, "respectGeminiIgnore": false } }, + "fileFiltering": { "respectGitIgnore": false, "respectGeminiIgnore": false } +} +``` + +設定の項目名は gemini の版で変わるため、**新旧どちらの形式でも書く**(0.55.1 で +読み取り成功を確認)。設定自身も差分に出さないよう `.gemini/` ごと無視する。 +配置は**手順書を配る前**に行う。gemini は起動時に 1 度だけ設定を読むためである。 + ### 読ませ方(明示パスを必ず書く) Skill を配置しても、**本文を読むかどうかはランタイムによって違う**。 @@ -150,7 +183,7 @@ for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ - --stem-template '{agent}-propose-rf{id}' + --stem-template "{agent}-propose-rf{id}-r$ROUND" ``` 3 CLI を並列で起動し、同一のプロンプトで提案させる。**提案フェーズにホストは現れない** @@ -159,6 +192,29 @@ done 提出形式は [prompts/propose.md](../prompts/propose.md) にある。 +### 結果ファイル名にラウンド番号を入れる + +CLI の起動時に同名の結果ファイルを消すため、**提案の結果ファイル名にもラウンド番号が +要る**。番号を入れないと、2 巡目の提案が始まった時点で 1 巡目の提案内容が失われる。 +統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは復元できない**。 + +| フェーズ | ファイル名 | +| --- | --- | +| 提案 | `<ランタイム>-propose-rf-r<ラウンド>-result.json` | +| 適用 | `<ランタイム>-apply-r<ラウンド>-result.json` | +| レビュー | `<ランタイム>-review-r<ラウンド>-result.json` | + +### 語彙は列挙して渡す + +提案プロンプトは `smell` / `technique` / `severity` の**許容値を列挙する**。 +「手順書の語彙に限定する」とだけ書くと、手順書の見出しが日本語であるため、読んだ側が +日本語を語彙と解釈する。実測では gemini の提案 4 件が全て日本語の語彙で返り、 +内容は妥当だったにもかかわらず語彙外の降格規則で全件が見送りになった。 + +列挙の元は `refactor.py` の `SMELLS` / `TECHNIQUES`(検証側が持つ集合)である。 +`init` が状態ファイルの `vocabulary` へ書き、`launch-cli.sh` が読んで差し込む。 +**同じ一覧を 2 か所に書かない。** + ## Step 3: 提案のマージ ```bash diff --git a/plugins/ndf-claude/skills/cross-refactoring/docs/02-apply-and-review.md b/plugins/ndf-claude/skills/cross-refactoring/docs/02-apply-and-review.md index 93146ae1..e9b460b8 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/docs/02-apply-and-review.md +++ b/plugins/ndf-claude/skills/cross-refactoring/docs/02-apply-and-review.md @@ -8,9 +8,13 @@ "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-apply-r$ROUND" --timeout 3600 -"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 終了コード 2 = 全件失敗 +"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 2 = 全件失敗 / 4 = 中断 ``` +終了コード 2 と 4 を**必ず区別する**。同じ扱いにすると、取り消しに失敗した状態を +「全件失敗」として次の提案ラウンドへ進み、検証を通っていない変更が Pull Request に +残ったまま新しい提案が始まる(実測)。 + 実装担当を**1 ラウンド 1 回**起動し、採用した改善項目を優先度順に**直列適用**させる。 並列適用はしない(同一ブランチへの同時コミットは競合とレビュー単位の曖昧化を招く)。 @@ -36,8 +40,38 @@ | テストが無い経路は先に現状固定テスト | `git show --name-only` | `test_gap` が真の項目は、先頭コミットがテストの置き場所を触っている | | 項目の分離 | git のトレーラー | 各コミットの `Item-Id` がその項目と一致する。複数の項目を 1 コミットにまとめたら失敗 | | 差分予算 | `git show --numstat` | 実差分の合計が `estimated_diff_lines` の 2 倍を超えたら失敗(範囲の逸脱) | +| 対象範囲の遵守 | `git show --name-only` | 触ったファイルが全て `--scope` の中にある。1 つでも外なら失敗 | | 機能変更の混入なし | — | 機械判定は不可能。レビュー観点に委ねる | +#### 範囲の指定は検証にも効かせる + +`--scope` を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を検証へ +反映しないと目的を果たせない。実測では、編集元から配布物を生成する規約に従った結果として +範囲外の 3 系統が変更され、差分が 4 倍に膨らんで差分予算を超えた。実装担当の判断自体は +リポジトリの規約に沿っており、**規約と範囲の指定が衝突していた**のが原因である。 + +そこで責務を分ける。 + +| 誰が | 何を | +| --- | --- | +| 実装担当 | `--scope` の中だけを変更する。生成物・配布物の同期はしない | +| 進行側(ホスト) | 収束後にまとめて生成物を同期する | + +判定は**前方一致だけ**で行い、除外規則は持たない。規則を書けるようにすると、 +規則を 1 行足すだけで範囲の検査を骨抜きにできる。 + +そのため **`--scope` には現状固定テストの置き場所も含める**。含めないと、 +`test_gap` が真の項目で「テストを先に足せ」と「範囲外を触るな」が両立しなくなり、 +その項目は必ず失敗する。 + +```bash +# ❌ テストの置き場所が入っていない +--scope src/services + +# ✅ 直す対象とテストの置き場所を両方入れる +--scope src/services tests/services +``` + テストの実行はコミットごとに `git checkout --detach ` して行い、終わったら 必ず元のブランチへ戻す。1 ラウンドの採用上限があるため実行回数は数回に収まり、 CLI の起動コストに比べれば無視できる。 @@ -101,9 +135,28 @@ Pull Request に残る。**都合の悪い変更を申告しないだけで検 **1 件の失敗でラウンドを止めない。** 失敗した項目だけを見送りにして、残りは採用する。 全件失敗のときだけ終了コード 2 を返し、次の提案ラウンドへ進む。 -**失敗した項目のコミットはその場で取り消して push する。** 実装担当は項目ごとに -push しているため、状態を `abandoned` にするだけでは差分が Pull Request に残り、 -以後のレビュー対象にも混入する。何が消えるかを先に見たいときは `--dry-run` を付ける。 +#### 判定はその都度記録する + +**項目ごとの判定が出るたびに状態ファイルへ保存する。** まとめて最後に保存すると、 +取り消しの途中で中断したときに適用の記録が一切残らない。実測では 14 件の適用コミットと +3 件の取り消しコミットが実在するのに、状態ファイルは全項目 `pending` / コミット 0 件の +ままだった。**どのコミットが検証を通ったのかを状態から復元できず、同じ手順を叩き直しても +再開できない。** 再開可能性は収束ループの前提なので、ここが崩れると復帰手段が無くなる。 + +記録先は `rounds[].apply_progress`(項目 ID / 判定 / 理由 / コミット)である。 + +#### 取り消しは判定が出そろってからまとめて行う + +**失敗した項目のコミットを Pull Request に残さない。** 実装担当は項目ごとに push して +いるため、状態を `abandoned` にするだけでは差分が残り、以後のレビュー対象にも混入する。 +何が消えるかを先に見たいときは `--dry-run` を付ける。 + +ただし**項目ごとにその場で戻してはならない**。詳細は +[取り消しは巻き戻して積み直す](#取り消しは巻き戻して積み直す)を参照する。 + +**取り消しへ着手する前に `pending_push` を立てる。** 取り消しは済んだのに push できずに +終わると、Pull Request 側には未検証の差分が残るのに、次の実行は処理済みガードで +素通りしてしまう。印があれば、次の実行が判定より先に再送信する。 ### コミットトレーラーの形式 @@ -255,10 +308,10 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 修正ラウンドが上限に達したら、**未解決の指摘が紐づく改善項目だけ**を取り消す。 指摘の無い項目と解決済みの項目は Pull Request に残す。 -- 取り消しは**git の履歴から新しい順に並べ直して** `git revert` する。申告された - 順序は信用しない。古いコミットから戻すと後続の取り消しが競合して止まる +- 取り消しは**範囲を新しい順に全て戻してから、残す項目を積み直す**(次節) - 取り消しに失敗したら、**着手前の HEAD まで戻してから**中断する。先行して成功した - 取り消しだけが履歴に残ると、再実行で不整合になって進めなくなる + 取り消しだけが履歴に残ると、再実行で不整合になって進めなくなる。中断は + **終了コード 4** で表し、「全件失敗」(2)と区別する - **保存してから push する。** 逆順にすると、push の失敗時に取り消しはローカルへ 残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう - push の前に `pending_push` を立て、成功したら消す。失敗したまま終わっても、 @@ -275,6 +328,63 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 修正は必須だが、リファクタリングは任意の作業なので、揉める提案を Pull Request に 残さない方が安全である。 +### 取り消しは巻き戻して積み直す + +**項目のコミットだけを新しい順に戻す方法では足りない。** その並べ替えは同じ項目に属する +コミットの中でしか働かず、取り消し対象より新しい**別項目**のコミットが同じ箇所を触って +いると必ず競合する。実測では、採用した 5 件のうち 4 件が同一ファイルの隣接領域を変更して +おり、取り消しが競合して進行が止まった。 + +```text +❌ R1-002 のコミット ea3209c を取り消せませんでした: error: could not revert ea3209c... +``` + +そこで次の順で行う。 + +```mermaid +flowchart LR + A["範囲 base..HEAD を
新しい順に全て revert"] --> B["残す項目のコミットを
古い順に cherry-pick"] + B -->|成功| C["項目単位の取り消し完了"]:::ok + B -->|競合| D["着手前 HEAD へ reset
範囲を全て revert"] --> E["ラウンド全件を取り消し"]:::stop + + classDef ok fill:#dfd,stroke:#383 + classDef stop fill:#fdd,stroke:#933 +``` + +- 範囲全体を新しい順にたどる取り消しは**履歴の逆再生**なので競合しない。競合するのは + 「一部のコミットだけを飛ばして戻す」ときである +- 積み直しの対象は**残す項目に属するコミットだけ**。過去の取り消しコミットのように + どの項目にも属さないものは積み直さない +- **積み直しで SHA が変わる。** 状態ファイルの `items[].commits` を新しい SHA へ + 更新する。更新しないと、次の取り消しが履歴に無い SHA を指す +- `git push --force` は使わない。履歴の書き換えではなく、**revert と cherry-pick による + 前進だけ**で行う + +#### 隣接する変更は分離できない + +**項目単位で取り消せるのは、項目どうしの変更が独立しているときだけである。** +取り消す側と残す側が同一ファイルの隣接行を触っていると、積み直しの +`git cherry-pick` も競合する。取り消した側の行が消えることで、残す側のパッチが +前提にしている文脈も消えるためで、これは git だけでは決められない。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル | 項目単位 | +| 同一ファイルの離れた行 | 項目単位 | +| 同一ファイルの隣接行 | **ラウンド全件へ退避** | + +退避したときは着手前の状態まで戻し、ラウンドの全項目を見送る。半端な履歴を残すより、 +決定的な状態へ落とす方が安全である。退避したことは `rounds[].drops[].mode` に +`round` として残るので、頻度は報告から読める。 + +実測(Pull Request #118)では採用 5 件のうち 4 件が同一ファイルの隣接領域を変更して +いた。**この構成では退避が普通に起こる**と見込んでおく。項目単位を保ちたいなら、 +`--max-items-per-round` を下げるか、`--scope` を狭めて 1 ラウンドで同じファイルの +近い場所を複数触らせないようにする。 + +範囲の起点を記録していない状態ファイル(旧版)では積み直せないため、従来どおり +項目のコミットだけを新しい順に戻す。この経路では取り消し自体が競合しうる。 + ## Step 7: 提案ラウンドの収束と最終ゲート ```bash @@ -292,7 +402,11 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 重複率は `path` + `symbol` + `smell` の集合比較で求める。同じ提案が毎ラウンド出続けて 終わらない状態を検知するためである。 -終了後は **`/ndf:cross-review `** で Pull Request 全体を承認収束にかける。 +終了後、生成物の同期が要るリポジトリでは**ここで進行側がまとめて同期する**。 +実装担当に同期させると範囲外の変更が生まれ、差分予算にも影響する(Step 4 の +「範囲の指定は検証にも効かせる」を参照)。 + +続けて **`/ndf:cross-review `** で Pull Request 全体を承認収束にかける。 レビューはラウンド単位なので、**ラウンドを跨いだ整合はここで見る**。 ## Step 8: 報告 diff --git a/plugins/ndf-claude/skills/cross-refactoring/prompts/apply.md b/plugins/ndf-claude/skills/cross-refactoring/prompts/apply.md index 0db2b60f..ad7fac79 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/prompts/apply.md +++ b/plugins/ndf-claude/skills/cross-refactoring/prompts/apply.md @@ -56,6 +56,11 @@ Impl-Model: $RF_MODEL - **`git push --force` と `--no-verify` を使わない** - 作業ディレクトリの外を触らない +- **対象範囲(`$RF_SCOPE`)の外にあるファイルを 1 つも変更しない。** 範囲外を触った + コミットを含む項目は検証で失敗し、取り消されます +- **生成物・配布物の同期をしない。** このリポジトリに「編集元から配布物を生成する」 + 規約があっても、同期は**進行側が収束後にまとめて行う**責務です。ここで同期すると + 範囲外の変更が生まれ、差分予算も超えます - **機能変更を混ぜない。** 振る舞いを変える修正が必要だと分かったら、その項目は 適用せず `status` を `skipped` にして理由を書く - 提案された手順の範囲を超えない。ついでの整理をしない diff --git a/plugins/ndf-claude/skills/cross-refactoring/prompts/fix.md b/plugins/ndf-claude/skills/cross-refactoring/prompts/fix.md index df2814e7..b28b967d 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/prompts/fix.md +++ b/plugins/ndf-claude/skills/cross-refactoring/prompts/fix.md @@ -57,6 +57,9 @@ Impl-Model: $RF_MODEL - **`git push --force` と `--no-verify` を使わない** - 作業ディレクトリの外を触らない +- **対象範囲(`$RF_SCOPE`)の外にあるファイルを 1 つも変更しない。** 範囲外を触った + 修正コミットがあると、その修正ラウンドの範囲ごと取り消されます +- **生成物・配布物の同期をしない。** 同期は進行側が収束後にまとめて行います - 指摘に無い箇所を「ついでに」直さない。ラウンドの差分が膨らみ、 どの変更がどの指摘に対応するのか追えなくなる diff --git a/plugins/ndf-claude/skills/cross-refactoring/prompts/propose.md b/plugins/ndf-claude/skills/cross-refactoring/prompts/propose.md index 2127e31b..cc299fee 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/prompts/propose.md +++ b/plugins/ndf-claude/skills/cross-refactoring/prompts/propose.md @@ -20,6 +20,24 @@ $RF_SKILL_BLOCK $RF_EXCLUDED +## 語彙(この値だけを使う) + +`smell` と `technique` には**下の識別子をそのまま**書いてください。日本語の説明は +意味の対応を示すためのもので、**出力に日本語を書くと語彙外**として扱われ、 +自動的に最低の重要度へ降格し、しきい値で落ちます。 + +### `smell` に書ける値 + +$RF_VOCAB_SMELLS + +### `technique` に書ける値 + +$RF_VOCAB_TECHNIQUES + +### `severity` に書ける値 + +$RF_VOCAB_SEVERITIES + ## やること 1. 対象範囲のコードを読み、**調べる価値がある兆候**を探す @@ -54,9 +72,8 @@ $RF_EXCLUDED ``` - `path` はリポジトリ相対、`symbol` は関数・メソッド・クラスの名前 -- `smell` と `technique` は**手順書の語彙に限定**する。語彙外の値は自動で最低の - 重要度へ降格され、しきい値で落ちます -- `severity` は `critical` / `major` / `minor` のいずれか +- `smell` / `technique` / `severity` は**上に列挙した識別子のいずれか**。 + 列挙にない値(日本語を含む)は自動で最低の重要度へ降格され、しきい値で落ちます - `path` + `symbol` + `smell` が同じ提案は 1 件へ統合されます。**他のランタイムと 合意した提案ほど優先される**ので、独自性を狙わず素直に挙げてください diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/launch-cli.sh b/plugins/ndf-claude/skills/cross-refactoring/scripts/launch-cli.sh index 1f221e20..c2d7468c 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/launch-cli.sh +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/launch-cli.sh @@ -45,7 +45,10 @@ MAX_ITEMS=$(jq -r '.max_items_per_round' "$STATE") case "$PHASE" in propose) - STEM=$TMP_DIR/$RUNTIME-propose-rf$ID + # **提案にもラウンド番号を入れる。** 起動時に同名の結果ファイルを消すため、 + # 番号が無いと 2 巡目の提案が 1 巡目の内容を消してしまう。 + [ "$ROUND" -ge 1 ] 2>/dev/null || { echo "propose には ROUND が必要です" >&2; exit 1; } + STEM=$TMP_DIR/$RUNTIME-propose-rf$ID-r$ROUND WORKDIR=$ROOT/$RUNTIME ;; apply|fix) @@ -112,7 +115,21 @@ export RF_MODEL=${MODEL:-default} RF_WORKDIR=$WORKDIR RF_STEM=$STEM export RF_SCOPE=$SCOPE RF_HEAD_BRANCH=$HEAD_BRANCH RF_BASE_BRANCH=$BASE_BRANCH export RF_BASELINE_TEST=$BASELINE_TEST RF_MAX_ITEMS=$MAX_ITEMS export RF_SKILL_BLOCK=$SKILL_BLOCK RF_EXCLUDED=$EXCLUDED + +# 語彙の許容値。**手順書を読ませるだけでは足りない。** 手順書の見出しは日本語なので、 +# 「語彙に限定する」とだけ書くと読んだ側が日本語を語彙と解釈し、語彙外の降格規則で +# 全件が見送りになる(実測)。検証側が持つ集合を状態ファイル経由で受け取り、 +# **許容値をそのまま列挙する**。 +VOCAB_SMELLS=$(jq -r '(.vocabulary.smells // {}) | to_entries[] | "- `\(.key)` — \(.value)"' "$STATE") +VOCAB_TECHNIQUES=$(jq -r '(.vocabulary.techniques // {}) | to_entries[] | "- `\(.key)` — \(.value)"' "$STATE") +VOCAB_SEVERITIES=$(jq -r '(.vocabulary.severities // []) | map("`" + . + "`") | join(" / ")' "$STATE") +[ -n "$VOCAB_SMELLS" ] || VOCAB_SMELLS="(状態ファイルに語彙がありません。手順書の語彙に従うこと)" +[ -n "$VOCAB_TECHNIQUES" ] || VOCAB_TECHNIQUES="(同上)" +[ -n "$VOCAB_SEVERITIES" ] || VOCAB_SEVERITIES="\`critical\` / \`major\` / \`minor\`" + export RF_ITEMS=$ITEMS_JSON RF_TMP_DIR=$TMP_DIR +export RF_VOCAB_SMELLS=$VOCAB_SMELLS RF_VOCAB_TECHNIQUES=$VOCAB_TECHNIQUES +export RF_VOCAB_SEVERITIES=$VOCAB_SEVERITIES # 雛形は `${RF_*}` を展開するだけの素の Markdown。コマンド置換は展開しない # (プロンプト本文に `$(...)` や backtick が現れても実行させないため)。 diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/prepare-worktrees.sh b/plugins/ndf-claude/skills/cross-refactoring/scripts/prepare-worktrees.sh index ea0b05ce..c13afe38 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/prepare-worktrees.sh +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/prepare-worktrees.sh @@ -131,9 +131,41 @@ provision_skill() { # 代わりに、配置したディレクトリ自身へ全件無視の `.gitignore` を置く。自分自身も # 無視されるため差分に現れず、他の未追跡ファイルには影響しない。 ignore_dir() { + mkdir -p "$1" printf '*\n' > "$1/.gitignore" } +# gemini は**除外設定を読み取りにも適用する**。上の全件無視をそのまま効かせると、 +# 配置した手順書を `read_file` で一切開けない。 +# +# Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +# is ignored by configured ignore patterns. +# +# 手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と +# 書いている前提が崩れるため、**作業ディレクトリ限定で**読み取り側の除外を無効にする。 +# 設定の項目名は gemini の版で変わるので、新旧どちらの形式でも書く(0.55.1 で確認)。 +configure_gemini_reading() { + local base=$1 + local dir="$base/.gemini" + mkdir -p "$dir" + cat > "$dir/settings.json" <<'GEMINI_SETTINGS_EOF' +{ + "context": { + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } + }, + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } +} +GEMINI_SETTINGS_EOF + # 設定そのものも差分に出さない。`.gemini/` ごと無視する。 + printf '*\n' > "$dir/.gitignore" +} + HEAD_SHA=$(git rev-parse "origin/$HEAD_BRANCH" 2>/dev/null || git -C "$WORK" rev-parse HEAD) @@ -160,6 +192,9 @@ MISSING=() CONFLICT=() for rt in "${RUNTIMES[@]}"; do + # 配置より先に置く。gemini は起動時に 1 度だけ設定を読むため、 + # 手順書を配ってから設定を書いても間に合う保証がない。 + [ "$rt" = "gemini" ] && configure_gemini_reading "$ROOT/$rt" entry='{}' for name in "${REQUIRED_SKILLS[@]}"; do status=$(provision_skill "$ROOT/$rt" "$rt" "$name") diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 082dc773..74a99d3e 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -48,9 +48,19 @@ import models as models_lib # noqa: E402 import statefile # noqa: E402 -die = statefile.die info = statefile.info +# 中断の終了コード。**「全件失敗」(2)と区別する。** 進行スクリプトは 2 なら次の +# 提案ラウンドへ進み、4 なら進行そのものを止める。区別しないと、取り消しに失敗した +# 状態を「全件失敗」として握り潰し、**検証を通っていない変更を Pull Request に +# 残したまま**次の提案が始まる(実測)。 +ABORT = 4 + + +def die(msg: str, code: int = ABORT) -> None: + """中断して終了する。既定は「中断」を表す終了コード。""" + statefile.die(msg, code) + # ---------------- 語彙 ---------------- # スメルと手法の語彙は `refactoring` Skill の references と 1 対 1 で対応させる。 @@ -101,6 +111,25 @@ SEVERITY_ORDER = {"unknown": 0, "minor": 1, "major": 2, "critical": 3} DEFAULT_SEVERITY_THRESHOLD = "minor" +# 提案が名乗ってよい重要度。`unknown` は降格先なので含めない。 +SEVERITIES: tuple[str, ...] = tuple(s for s in SEVERITY_ORDER if s != "unknown") + + +def vocabulary() -> dict[str, Any]: + """提案プロンプトへ**そのまま列挙する**ための語彙集合。 + + 手順書の見出しは日本語なので、「語彙に限定する」とだけ書くと読んだ側が + 日本語を語彙と解釈する(実測では gemini の提案 4 件が全て日本語で返り、 + 語彙外の降格規則により全件見送りになった)。**検証側が持つ集合をそのまま + 渡す**ことで、許容値の定義を 1 箇所に保ったまま列挙できる。 + """ + return { + "smells": dict(SMELLS), + "techniques": dict(TECHNIQUES), + "severities": list(SEVERITIES), + } + + # 適用と修正のコミットに必須のトレーラー。1 つでも欠けたら当該項目を失敗にする。 # 自由文で「codex が実装」と書かせると集計に使えないため、必ずトレーラー形式にする。 REQUIRED_TRAILERS = ("Item-Id", "Round", "Impl-Runtime", "Impl-Model") @@ -122,6 +151,25 @@ # 差し戻しを無限に繰り返すと、形式を満たせないランタイムでループが止まらなくなる。 MAX_INVALID_REVIEWS = 1 +# 認証状態の確認コマンド。**CLI の存在確認だけでは足りない。** 未認証の CLI は +# 起動から 15 秒で終わり、結果ファイルを残さないまま担当から脱落する(実測)。 +# それでも初期化は成功として扱われるため、参加者が 1 人欠けた構成のまま進行する。 +AUTH_PROBES: dict[str, tuple[str, ...]] = { + "claude": ("claude", "auth", "status"), + "codex": ("codex", "login", "status"), + # gemini には認証確認の副コマンドが無い。最小のプロンプトで疎通を見る。 + # 作業ディレクトリの信頼判定に引っ掛からないよう `--skip-trust` を付ける。 + "gemini": ("gemini", "--skip-trust", "-p", "ping", "--output-format", "text"), + "kiro": ("kiro-cli", "whoami"), +} +AUTH_PROBE_TIMEOUT = 120 + +# **終了コード 0 でも未認証を示すことがある。** kiro は成否を終了コードで表さない。 +UNAUTHENTICATED_MARKERS = ( + "not logged in", "not authenticated", "authentication failed", + "login required", "unauthorized", "please log in", +) + # ---------------- パス解決 ---------------- @@ -203,9 +251,15 @@ def _result_path(state: dict[str, Any], runtime: str, stem: str) -> pathlib.Path def stem_for(runtime: str, phase: str, state_id: int, round_no: Optional[int] = None) -> str: - """一時ファイル名の骨格。監視スクリプトの `--stem-template` と揃える。""" + """一時ファイル名の骨格。監視スクリプトの `--stem-template` と揃える。 + + **提案にもラウンド番号を入れる。** CLI の起動時に同名の結果ファイルを消すため、 + 番号が無いと 2 巡目の提案が始まった時点で 1 巡目の提案内容が失われる。 + 統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは + 復元できなくなる**(実測)。 + """ if phase == "propose": - return f"{runtime}-propose-rf{state_id}" + return f"{runtime}-propose-rf{state_id}-r{round_no}" return f"{runtime}-{phase}-r{round_no}" @@ -364,6 +418,51 @@ def duplicate_rate( # **git と実際のテスト実行**から取る。結果ファイルから使うのは「どのコミットが # どの項目のものか」という対応付けの手がかりだけである。 +def path_in_scope(path: str, scope: Iterable[str]) -> bool: + """`path` が対象範囲の中にあるか。判定は**前方一致だけ**で行う。 + + 除外規則を足さない。規則を書けるようにすると、規則を 1 行足すだけで + 範囲の検査を骨抜きにできてしまう。 + """ + for entry in scope: + prefix = str(entry).strip().rstrip("/") + if not prefix: + continue + if path == prefix or path.startswith(prefix + "/"): + return True + return False + + +def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: + """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" + paths = list(scope) + if not paths: + return [] + return sorted( + p for p in (commit.get("files") or []) if not path_in_scope(p, paths) + ) + + +def verify_scope(commit: dict[str, Any], scope: Iterable[str]) -> Optional[str]: + """対象範囲の外を触っていれば理由を返す。 + + 範囲を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を + 検証に反映しないと目的を果たせない。実測では、生成物を同期する規約に従った + 結果として範囲外が 3 系統変更され、差分が 4 倍に膨らんで差分予算を超えた。 + 生成物の同期が要る構成では、**同期は進行側の責務**として分離する。 + """ + outside = out_of_scope_files(commit, scope) + if not outside: + return None + shown = ", ".join(outside[:5]) + more = f" ほか {len(outside) - 5} 件" if len(outside) > 5 else "" + return ( + f"コミット {commit.get('sha', '?')} が対象範囲の外を変更しています" + f"({shown}{more})。生成物の同期は進行側が収束後にまとめて行います。" + "現状固定テストの置き場所が範囲外なら、`--scope` に含めてから実行してください" + ) + + def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: """コミットのトレーラーが 4 つ揃っているか。欠けていれば理由を返す。 @@ -377,7 +476,9 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という @@ -386,6 +487,9 @@ def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: if not commit.get("exists", True): return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -397,7 +501,8 @@ def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: def verify_apply_item( - item: dict[str, Any], facts: list[dict[str, Any]] + item: dict[str, Any], facts: list[dict[str, Any]], + scope: Optional[Iterable[str]] = None, ) -> Optional[str]: """1 項目の適用結果を検証する。問題があれば失敗理由を返す。 @@ -415,6 +520,9 @@ def verify_apply_item( "(申告だけで実体がありません)" ) problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -531,6 +639,56 @@ def unresolved_item_ids( # ---------------- サブコマンド ---------------- +def check_auth(runtimes: Iterable[str]) -> dict[str, dict[str, Any]]: + """参加する CLI の認証状態を確かめる。1 つでも欠けたら初期化を中断する。 + + 存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、結果ファイルを + 残さないまま提案・レビューの担当から脱落するが、**初期化は成功として扱われる** + ため、参加者が 1 人欠けた構成のまま最後まで進んでしまう。 + + 確認コマンドは CLI の版で変わりうるので、`NDF_SKIP_AUTH_CHECK` で飛ばせるように + しておく。飛ばしたことは必ず出力へ残す(黙って劣化させない)。 + """ + if os.environ.get("NDF_SKIP_AUTH_CHECK"): + info("⚠ NDF_SKIP_AUTH_CHECK が設定されているため認証確認を飛ばしました") + return {} + + results: dict[str, dict[str, Any]] = {} + failed: list[str] = [] + for runtime in runtimes: + probe = AUTH_PROBES.get(runtime) + if probe is None: + continue + env = dict(os.environ) + if runtime == "gemini": + # 新規パスは untrusted と判定されるため、確認でも信頼を明示する。 + env["GEMINI_CLI_TRUST_WORKSPACE"] = "true" + try: + r = subprocess.run(list(probe), capture_output=True, text=True, + timeout=AUTH_PROBE_TIMEOUT, env=env) + merged = f"{r.stdout}\n{r.stderr}".lower() + ok = r.returncode == 0 and not any( + m in merged for m in UNAUTHENTICATED_MARKERS + ) + detail = (r.stderr.strip() or r.stdout.strip())[:200] + except FileNotFoundError: + ok, detail = False, "コマンドが見つかりません" + except subprocess.TimeoutExpired: + ok, detail = False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" + results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} + info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") + if not ok: + failed.append(f"{runtime}({detail})") + + if failed: + die( + "認証されていない CLI があります: " + " / ".join(failed) + "。" + "参加者が欠けたまま進むと、その者の提案とレビューが無いまま収束します。" + "各 CLI でログインしてから再実行してください" + ) + return results + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -553,6 +711,10 @@ def cmd_init(args: argparse.Namespace) -> None: if host in runtimes: die(f"提案・レビューの母集合にホスト {host} が含まれています(判定の誤り)") + # **認証は作業ディレクトリを作る前に確かめる。** 未認証のまま進むと、 + # 参加者が欠けた構成のまま最後まで走り切ってしまう。 + auth = check_auth(sorted(set(runtimes) | set(impl_capable))) + repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) head_branch = _sh( ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] @@ -597,6 +759,10 @@ def cmd_init(args: argparse.Namespace) -> None: "runtimes": runtimes, "impl_capable": impl_capable, "models": model_spec, + "auth": auth, + # 提案プロンプトへ許容値をそのまま列挙するために持たせる。 + # 定義は検証側(この CLI)にあり、状態ファイル経由で起動側へ渡す。 + "vocabulary": vocabulary(), "skills": {"required": list(REQUIRED_SKILLS)}, "max_outer_rounds": args.max_outer_rounds, "max_fix_rounds": args.max_fix_rounds, @@ -816,7 +982,10 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: - result = _result_path(state, runtime, stem_for(runtime, "propose", state["id"])) + result = _result_path( + state, runtime, + stem_for(runtime, "propose", state["id"], entry["round"]), + ) if not result.exists(): info(f"⚠ {runtime} の提案結果がありません: {result}") continue @@ -1043,6 +1212,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "item_id": f"R{entry['round']}-range", "commits": list(ordered_range), } + if not args.dry_run: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) _revert_item_commits(state, whole_round, args.dry_run) if not args.dry_run: # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 @@ -1060,12 +1234,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: - _push_with_retry_marker(path, state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) sys.exit(2) applied: list[str] = [] failed: list[str] = [] - reverted = 0 + scope = state.get("target_scope") or [] + # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した + # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から + # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると + # 中断からの復帰手段が無くなる。 + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress for item_id in entry["items"]: item = _find_item(state, item_id) got = reported.get(item_id) @@ -1077,25 +1260,31 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: work, _reported_shas(got), in_range, test_command, head_branch, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - problem = verify_apply_item(item, facts) + problem = verify_apply_item(item, facts, scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem item["test_failed"] = bool(got and "テストが成功していません" in problem) item["budget_exceeded"] = bool(got and "差分予算" in problem) - # **検証に失敗した項目のコミットを Pull Request に残さない。** - # 実装担当は項目ごとに push しているため、状態を `abandoned` にする - # だけでは差分が残り、以後のレビュー対象にも混入する。 + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, args.dry_run) failed.append(item_id) info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not args.dry_run: + statefile.save(path, state) entry["apply"] = { "applied": applied, @@ -1114,12 +1303,39 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の # つもりで実行した利用者の進行が壊れる。 if args.dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") + elif failed: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、検証を通っていない変更が Pull Request に残り、次の実行は + # 処理済みガードで素通りしてしまう。 + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, failed) + if result["mode"] == "round": + # 積み直せなかった。合意済みの項目も含めて全件捨てる。 + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it.setdefault( + "failure_reason", + "残す項目を積み直せなかったため、ラウンドごと取り消した", + ) + applied, failed = [], list(entry["items"]) + entry["apply"]["applied"] = applied + entry["apply"]["failed"] = failed + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + # 保存してから push する。push が失敗しても、記録とローカルの git が + # 食い違わない。 + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) else: - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) - if reverted: - _push_with_retry_marker(path, state, entry) if not applied: info("全項目が失敗したため、このラウンドのレビューは行いません") @@ -1291,27 +1507,42 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) return + if args.dry_run: + _drop_items(state, entry, targets, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + return + + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、Pull Request 側には未検証の差分が残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, targets) + if result["mode"] == "round": + info("積み直せなかったため、このラウンドで適用した項目を全件見送ります") + targets = list(entry["apply"].get("applied") or targets) + + already = {d.get("item_id") for d in state["deferred_items"]} for item_id in targets: item = _find_item(state, item_id) - count = _revert_item_commits(state, item, args.dry_run) item["status"] = "abandoned" item.setdefault("failure_reason", "修正ラウンドの上限に達しても指摘が解決しなかった") + if item_id in already: + continue state["deferred_items"].append({ "item_id": item_id, "path": item["path"], "symbol": item["symbol"], "smell": item["smell"], "round": entry["round"], "defer_reason": item["failure_reason"], }) - info(f"↩ {item_id} を取り消しました({count} コミット)") + info(f"↩ {item_id} を見送りました") entry["abandoned"] = targets state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - return # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) - _push_with_retry_marker(path, state, entry) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) def cmd_merge_fix(args: argparse.Namespace) -> None: @@ -1416,7 +1647,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: needs_push = False for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) + problem = verify_fix_commit(commit, state.get("target_scope") or []) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1433,6 +1664,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) _revert_item_commits( state, {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", @@ -1706,6 +1941,12 @@ def commit_diff_lines(work: str, sha: str) -> int: return total +def commit_files(work: str, sha: str) -> list[str]: + """コミットが触ったファイルのリポジトリ相対パス。範囲の検査に使う。""" + out = _git_out(work, ["show", "--name-only", "--format=", sha]) + return [p.strip() for p in (out or "").splitlines() if p.strip()] + + def commit_touches_tests(work: str, sha: str) -> bool: """コミットがテストの置き場所を触っているか。""" out = _git_out(work, ["show", "--name-only", "--format=", sha]) @@ -1843,6 +2084,7 @@ def collect_commit_facts( "exists": True, "trailers": commit_trailers(work, full), "diff_lines": commit_diff_lines(work, full), + "files": commit_files(work, full), "touches_tests": commit_touches_tests(work, full), "test_status": run_test_at( work, full, test_command, head_branch, test_timeout @@ -1958,6 +2200,171 @@ def _revert_item_commits( return len(shas) +def _reset_hard(work: str, sha: Optional[str]) -> None: + """着手前の HEAD へ戻す。半端な履歴を Pull Request に残さないための後始末。""" + if sha: + subprocess.run(["git", "reset", "--hard", sha], cwd=work, + capture_output=True, text=True) + + +def _revert_range(work: str, ordered: list[str], before: Optional[str]) -> None: + """範囲を**新しい順に**全て取り消す。失敗したら着手前へ戻して中断する。 + + 範囲全体を新しい順にたどる取り消しは、履歴をそのまま逆再生するだけなので + **競合しない**。競合するのは「一部のコミットだけを飛ばして戻す」ときである。 + """ + for sha in ordered: + r = subprocess.run( + ["git", "revert", "--no-edit", sha], + cwd=work, capture_output=True, text=True, + ) + if r.returncode != 0: + subprocess.run(["git", "revert", "--abort"], cwd=work, + capture_output=True, text=True) + _reset_hard(work, before) + die( + f"コミット {sha} を取り消せませんでした: {r.stderr.strip()[:400]}" + f"(HEAD を {before} へ戻しました)" + ) + + +def _replay_commits(work: str, shas: list[str]) -> Optional[dict[str, str]]: + """残す項目のコミットを**古い順に**積み直し、`{元の SHA: 新しい SHA}` を返す。 + + 競合したら `None` を返す。**ここで中断しない。** どの項目を残せるか決められない + だけなので、呼び出し側がラウンド全件の取り消しへ退避できる。 + """ + mapping: dict[str, str] = {} + for sha in shas: + r = subprocess.run( + ["git", "cherry-pick", "--allow-empty", sha], + cwd=work, capture_output=True, text=True, + ) + if r.returncode != 0: + subprocess.run(["git", "cherry-pick", "--abort"], cwd=work, + capture_output=True, text=True) + info(f"⚠ {sha[:7]} を積み直せませんでした: {r.stderr.strip()[:200]}") + return None + mapping[sha] = _git_out(work, ["rev-parse", "HEAD"]) or sha + return mapping + + +def _commit_owner( + work: str, state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, str]: + """このラウンドの `コミット → 改善項目 ID` の対応。完全な SHA へ正規化する。 + + どの項目にも属さないコミット(過去の取り消しなど)はここに現れない。 + 積み直しの対象から外すために、**属さないこと**を判定できる形にしておく。 + """ + owner: dict[str, str] = {} + for item_id in entry["items"]: + item = _find_item(state, item_id, required=False) + if item is None: + continue + for sha in item.get("commits") or []: + if not isinstance(sha, str) or not sha.strip(): + continue + full = _git_out(work, ["rev-parse", "--verify", f"{sha.strip()}^{{commit}}"]) + owner[full or sha.strip()] = item_id + return owner + + +def _drop_items( + state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], + dry_run: bool = False, +) -> dict[str, Any]: + """改善項目を取り消し、残す項目を積み直す。 + + **範囲を新しい順に全て戻してから、残す項目を古い順に積み直す。** 項目のコミット + だけを戻すと、取り消し対象より新しい**別項目**のコミットが同じ箇所を触っている + ときに必ず競合する(実測では採用 5 件のうち 4 件が同一ファイルの隣接領域を + 変更しており、取り消しが競合して進行が止まった)。 + + 積み直しが競合したときは着手前 HEAD へ戻し、**ラウンド全件の取り消しへ退避する**。 + どの項目を残せるか決められない以上、半端な履歴を残すより全件捨てる方が安全である。 + + 戻り値の `mode` は次の 3 つ。 + + | 値 | 意味 | + | --- | --- | + | `item` | 項目単位で取り消し、残す項目を積み直した | + | `round` | 積み直せず、ラウンド全件を取り消した(退避) | + | `skip` | 取り消すものが無かった(取り消し済み) | + """ + work = state["worktrees"]["work"] + pending = [ + i for i in drop_ids + if not (_find_item(state, i, required=False) or {}).get("reverted") + ] + if not pending: + info("↩ 取り消し対象は取り消し済みです") + return {"mode": "skip", "dropped": [], "reverted": 0, "replayed": 0} + + head = _git_out(work, ["rev-parse", "HEAD"]) + ordered = commits_in_range(work, entry.get("apply_base_sha"), head or "HEAD") + if ordered is None: + # 起点を記録していない状態ファイル(旧版)では積み直せない。 + # 従来どおり項目のコミットだけを新しい順に戻す。 + info("⚠ 適用の範囲を確定できないため、項目のコミットだけを取り消します") + reverted = 0 + for item_id in pending: + reverted += _revert_item_commits(state, _find_item(state, item_id), dry_run) + return {"mode": "item", "dropped": pending, + "reverted": reverted, "replayed": 0} + + owner = _commit_owner(work, state, entry) + drop = set(pending) + keep_ids = [ + i for i in entry["items"] + if i not in drop + and not (_find_item(state, i, required=False) or {}).get("reverted") + ] + # `ordered` は新しい順なので、積み直しは反転して古い順にする。 + # **どの項目にも属さないコミット(過去の取り消しなど)は積み直さない。** + replay = [s for s in reversed(ordered) if owner.get(s) in keep_ids] + + if dry_run: + for sha in ordered: + info(f"(dry-run)git revert --no-edit {sha}") + for sha in replay: + info(f"(dry-run)git cherry-pick {sha}") + return {"mode": "item", "dropped": pending, + "reverted": len(ordered), "replayed": len(replay)} + + _revert_range(work, ordered, head) + mapping = _replay_commits(work, replay) + mode = "item" + if mapping is None: + info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") + _reset_hard(work, head) + _revert_range(work, ordered, head) + mapping, mode = {}, "round" + + dropped = list(entry["items"]) if mode == "round" else pending + for item_id in entry["items"]: + item = _find_item(state, item_id, required=False) + if item is None: + continue + if mode == "round" or item_id not in keep_ids: + item["reverted"] = True + continue + # **積み直しで SHA が変わる。** 記録を更新しないと、次の取り消しが + # 履歴に無い SHA を指してしまう。 + item["commits"] = [mapping[s] for s in replay if owner.get(s) == item_id] + + entry.setdefault("drops", []).append({ + "at": statefile.now(), "mode": mode, "dropped": dropped, + "reverted": len(ordered), "replayed": len(mapping), + }) + info( + f"↩ 取り消し {len(ordered)} コミット / 積み直し {len(mapping)} コミット" + f"({'ラウンド全件へ退避' if mode == 'round' else '項目単位'})" + ) + return {"mode": mode, "dropped": dropped, + "reverted": len(ordered), "replayed": len(mapping)} + + def _order_newest_first(work: str, shas: list[str]) -> list[str]: """コミットを **git の履歴順(新しい順)** に並べ替える。 diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py index b6828948..e5c46d59 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py @@ -767,3 +767,106 @@ def failing_sh(cmd, **kw): assert [c for c in pushes if c[:2] == ["git", "push"]], "再試行していない" assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +# ---------- 巻き戻して積み直す取り消し ---------- + +def _range_state(tmp_path, findings, item_ids=("R1-001", "R1-002")): + """適用の起点を記録した状態。**積み直しの経路**を通る。""" + import json as _json + state_path = _state(tmp_path, findings, item_ids=item_ids) + state = read_state(state_path) + state["rounds"][0]["apply_base_sha"] = "BASE" + state_path.write_text(_json.dumps(state, ensure_ascii=False), encoding="utf-8") + return state_path + + +def _range_env(refactor, monkeypatch, ordered, pick_rc=0): + """範囲と git 操作を差し替える。`ordered` は新しい順。""" + calls: list[list[str]] = [] + picked: list[str] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + rc = 0 + if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: + rc = pick_rc + if rc == 0: + picked.append(cmd[-1]) + return subprocess.CompletedProcess(cmd, rc, "", "conflict" if rc else "") + + def fake_git_out(work, args): + if args[:2] == ["rev-parse", "--verify"]: + return args[-1].replace("^{commit}", "") + if args == ["rev-parse", "HEAD"]: + return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + return "HEAD_BEFORE" + + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + monkeypatch.setattr(refactor, "_git_out", fake_git_out) + monkeypatch.setattr(refactor, "commits_in_range", + lambda work, base, head: list(ordered)) + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: "") + return calls + + +def test_abandon_replays_the_items_that_stay( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """見送る項目より新しいコミットがあっても競合しないこと。 + + 範囲を新しい順に全て戻してから、残す項目を古い順に積み直す。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + # 履歴は R1-002 のコミットが新しい + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + + refactor.cmd_abandon_items(_args()) + + assert [c[-1] for c in calls if c[:2] == ["git", "revert"]] == [ + "sha-R1-002", "sha-R1-001"] + assert [c[-1] for c in calls if c[:2] == ["git", "cherry-pick"]] == ["sha-R1-002"] + + state = read_state(state_path) + by_id = {i["item_id"]: i for i in state["items"]} + assert by_id["R1-001"]["status"] == "abandoned" + assert by_id["R1-002"]["status"] == "reviewing" + assert by_id["R1-002"]["commits"] == ["new-sha-R1-002"] + + +def test_abandon_falls_back_to_the_whole_round_on_a_replay_conflict( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"], pick_rc=1) + + refactor.cmd_abandon_items(_args()) + + assert ["git", "cherry-pick", "--abort"] in calls + state = read_state(state_path) + assert all(i["status"] == "abandoned" for i in state["items"]) + assert sorted(d["item_id"] for d in state["deferred_items"]) == ["R1-001", "R1-002"] + assert state["rounds"][0]["drops"][-1]["mode"] == "round" + + +def test_abandon_marks_pending_push_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + marks: list[bool] = [] + _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + marks.append(read_state(state_path)["rounds"][0].get("pending_push")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + refactor.cmd_abandon_items(_args()) + + assert marks and marks[0] is True + assert read_state(state_path)["rounds"][0]["pending_push"] is False diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_drop_items_git.py new file mode 100644 index 00000000..bf828a03 --- /dev/null +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_drop_items_git.py @@ -0,0 +1,221 @@ +"""取り消しと積み直しを**実際の git** で確かめる。 + +差し替えたコマンド列だけを見ても「競合しないか」は示せない。ここでは本物の +リポジトリを作り、2 つの改善項目の位置関係を変えて挙動を確かめる。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル / 離れた行 | 項目単位で取り消し、残す項目は積み直せる | +| 同一ファイルの隣接行 | 積み直せないのでラウンド全件へ退避する | + +**隣接する変更は git だけでは分離できない。** 取り消した側の行が消えると、残す側の +パッチが前提にしている文脈も消えるためである。退避してでも Pull Request を +決定的な状態に保つことを優先する。 +""" +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git が必要") + +LINES = [f"line{i}\n" for i in range(1, 41)] + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, + text=True, check=True) + + +def _commit(repo, message): + _git("add", "-A", cwd=repo) + _git("-c", "user.email=t@e.st", "-c", "user.name=test", + "commit", "-qm", message, cwd=repo) + return _git("rev-parse", "HEAD", cwd=repo).stdout.strip() + + +def _make_repo(tmp_path, second_change): + """`R1-001` が 3 行目を、`R1-002` が `second_change` で示す箇所を変える。""" + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + _git("init", "-q", str(repo), cwd=tmp_path) + (repo / "src" / "foo.py").write_text("".join(LINES), encoding="utf-8") + (repo / "src" / "bar.py").write_text("".join(LINES), encoding="utf-8") + base = _commit(repo, "init") + + lines = list(LINES) + lines[2] = "line3-by-R1-001\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + c1 = _commit(repo, "R1-001") + + second_change(repo, lines) + c2 = _commit(repo, "R1-002") + return {"repo": repo, "base": base, "c1": c1, "c2": c2} + + +def _touch_adjacent_line(repo, lines): + lines = list(lines) + lines[3] = "line4-by-R1-002\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + + +def _touch_distant_line(repo, lines): + lines = list(lines) + lines[30] = "line31-by-R1-002\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + + +def _touch_other_file(repo, lines): + other = list(LINES) + other[2] = "line3-by-R1-002\n" + (repo / "src" / "bar.py").write_text("".join(other), encoding="utf-8") + + +@pytest.fixture +def adjacent_repo(tmp_path): + """同一ファイルの**隣接行**を触る 2 項目。実機で進行が止まった位置関係。""" + return _make_repo(tmp_path, _touch_adjacent_line) + + +@pytest.fixture +def distant_repo(tmp_path): + """同一ファイルの**離れた行**を触る 2 項目。""" + return _make_repo(tmp_path, _touch_distant_line) + + +@pytest.fixture +def separate_repo(tmp_path): + """**別ファイル**を触る 2 項目。""" + return _make_repo(tmp_path, _touch_other_file) + + +def _state(built): + entry = { + "round": 1, + "items": ["R1-001", "R1-002"], + "apply_base_sha": built["base"], + "apply": {"applied": ["R1-001", "R1-002"], "failed": []}, + } + state = { + "worktrees": {"work": str(built["repo"])}, + "rounds": [entry], + "items": [ + {"item_id": "R1-001", "round": 1, "status": "reviewing", + "commits": [built["c1"]]}, + {"item_id": "R1-002", "round": 1, "status": "reviewing", + "commits": [built["c2"]]}, + ], + } + return state, entry + + +def _content(built, name="foo.py"): + return (built["repo"] / "src" / name).read_text(encoding="utf-8") + + +# ---------- 前提の確認 ---------- + +def test_reverting_only_the_older_commit_conflicts(adjacent_repo): + """古い方だけを戻すと本当に競合すること。 + + これが競合しないなら、取り消しの作り直しそのものが不要になる。 + """ + r = subprocess.run( + ["git", "revert", "--no-edit", adjacent_repo["c1"]], + cwd=adjacent_repo["repo"], capture_output=True, text=True, + ) + assert r.returncode != 0, "競合しない位置関係になっている(テストの前提が崩れた)" + subprocess.run(["git", "revert", "--abort"], cwd=adjacent_repo["repo"], + capture_output=True, text=True) + + +# ---------- 項目単位で取り消せる場合 ---------- + +@pytest.mark.parametrize("fixture_name", ["distant_repo", "separate_repo"]) +def test_drop_older_item_keeps_the_newer_one(refactor, request, fixture_name): + """独立した変更なら、古い項目だけを取り消して新しい項目を残せること。""" + built = request.getfixturevalue(fixture_name) + state, entry = _state(built) + result = refactor._drop_items(state, entry, ["R1-001"]) + + assert result["mode"] == "item" + assert "line3-by-R1-001" not in _content(built), "取り消した項目の変更が残っている" + assert "R1-002" in _content(built) + _content(built, "bar.py"), \ + "残すはずの項目の変更が消えている" + + by_id = {i["item_id"]: i for i in state["items"]} + assert by_id["R1-001"]["reverted"] is True + # 積み直しで SHA が変わるので、記録も追従していること + head = _git("rev-parse", "HEAD", cwd=built["repo"]).stdout.strip() + assert by_id["R1-002"]["commits"] == [head] + + +def test_drop_newer_item_keeps_the_older_one(refactor, distant_repo): + """新しい項目だけを取り消す向きでも成立すること。""" + state, entry = _state(distant_repo) + assert refactor._drop_items(state, entry, ["R1-002"])["mode"] == "item" + assert "line3-by-R1-001" in _content(distant_repo) + assert "line31-by-R1-002" not in _content(distant_repo) + + +def test_second_drop_after_the_first_still_works(refactor, distant_repo): + """1 回目で積み直した SHA に対して、もう一度取り消せること。 + + 積み直しで SHA が変わるので、記録を更新していないとここで破綻する。 + """ + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001"]) + assert refactor._drop_items(state, entry, ["R1-002"])["mode"] == "item" + assert _content(distant_repo) == "".join(LINES) + + +def test_dropping_is_idempotent(refactor, distant_repo): + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001"]) + head = _git("rev-parse", "HEAD", cwd=distant_repo["repo"]).stdout.strip() + + assert refactor._drop_items(state, entry, ["R1-001"])["mode"] == "skip" + assert _git("rev-parse", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() == head + + +# ---------- 積み直せない場合 ---------- + +def test_adjacent_changes_fall_back_to_the_whole_round(refactor, adjacent_repo): + """隣接する変更は分離できない。退避して全件取り消すこと。 + + 半端な履歴を残すより、決定的な状態へ落とす方が安全である。 + """ + state, entry = _state(adjacent_repo) + result = refactor._drop_items(state, entry, ["R1-001"]) + + assert result["mode"] == "round" + assert _content(adjacent_repo) == "".join(LINES), "着手前の内容へ戻っていない" + assert all(i["reverted"] for i in state["items"]) + assert entry["drops"][-1]["mode"] == "round" + + +def test_dropping_every_item_returns_to_the_base_tree(refactor, distant_repo): + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001", "R1-002"]) + assert _content(distant_repo) == "".join(LINES) + + +def test_history_is_never_rewritten(refactor, distant_repo): + """`--force` を使わずに済むよう、前進だけで戻すこと。""" + state, entry = _state(distant_repo) + before = _git("rev-list", "--count", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() + refactor._drop_items(state, entry, ["R1-001"]) + after = _git("rev-list", "--count", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() + assert int(after) > int(before), "履歴を書き換えている" + assert built_commits_still_reachable(distant_repo) + + +def built_commits_still_reachable(built) -> bool: + """着手前のコミットが履歴から消えていないこと。""" + log = _git("rev-list", "HEAD", cwd=built["repo"]).stdout.split() + return built["c1"] in log and built["base"] in log diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_init.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_init.py index 9e566545..4987b7d6 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_init.py @@ -86,6 +86,9 @@ def fake_sh(cmd, cwd=None, check=True): monkeypatch.setattr(refactor, "_sh", fake_sh) monkeypatch.chdir(origin_repo) monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) + # 認証確認は実際の CLI を起動する。ここでは対象外なので飛ばす + # (確認そのものは `test_init_checks_cli_authentication` で見る)。 + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") refactor.cmd_init(args) return _run @@ -234,3 +237,94 @@ def test_diverged_worktree_stops_the_run(run_init, tmp_path): with pytest.raises(SystemExit): run_init(_args(tmp_path)) + + +# ---------- 語彙と認証 ---------- + +def test_init_records_the_vocabulary_for_the_prompt(run_init, tmp_path, refactor): + """許容値をプロンプトへ列挙できるよう、語彙集合を状態へ残すこと。 + + 手順書の見出しは日本語なので、「語彙に限定する」とだけ書くと読んだ側が + 日本語を語彙と解釈する(実測で gemini の提案 4 件が全件見送りになった)。 + """ + run_init(_args(tmp_path)) + _, state = _state_of(tmp_path) + assert state["vocabulary"]["smells"]["long_method"] == "長すぎるメソッド" + assert "extract_method" in state["vocabulary"]["techniques"] + assert state["vocabulary"]["severities"] == ["minor", "major", "critical"] + # 定義は検証側の 1 箇所だけに置く + assert state["vocabulary"]["smells"] == refactor.SMELLS + + +def _probe_result(refactor, monkeypatch, outcomes): + """認証確認コマンドの結果を差し替える。`{ランタイム: (rc, 出力)}`。""" + def fake_run(cmd, **kwargs): + for runtime, probe in refactor.AUTH_PROBES.items(): + if list(cmd) == list(probe): + rc, out = outcomes.get(runtime, (0, "ok")) + return subprocess.CompletedProcess(cmd, rc, out, "") + raise AssertionError(f"想定外の呼び出し: {cmd}") + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + + +def test_check_auth_passes_when_every_cli_is_logged_in(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {}) + results = refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + assert all(r["ok"] for r in results.values()) + + +def test_check_auth_fails_on_a_non_zero_exit(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {"kiro": (1, "")}) + with pytest.raises(SystemExit) as e: + refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + assert e.value.code == refactor.ABORT + + +def test_check_auth_fails_when_the_output_says_not_logged_in(refactor, monkeypatch): + """終了コード 0 でも未認証を示すことがある(kiro は成否を終了コードで表さない)。""" + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {"kiro": (0, "Not logged in")}) + with pytest.raises(SystemExit): + refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + + +def test_check_auth_fails_when_the_cli_is_missing(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + + def missing(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(refactor.subprocess, "run", missing) + with pytest.raises(SystemExit): + refactor.check_auth(["codex"]) + + +def test_check_auth_can_be_skipped_explicitly(refactor, monkeypatch): + """確認コマンドは CLI の版で変わる。飛ばせる逃げ道を残す。""" + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") + + def never(cmd, **kwargs): + raise AssertionError("認証確認を実行してはいけない") + + monkeypatch.setattr(refactor.subprocess, "run", never) + assert refactor.check_auth(["codex", "gemini"]) == {} + + +def test_init_checks_cli_authentication(refactor, origin_repo, monkeypatch, tmp_path): + """未認証の CLI があれば初期化ごと中断すること。 + + 参加者が 1 人欠けた構成のまま進むと、その者の提案とレビューが無いまま収束する。 + """ + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + monkeypatch.chdir(origin_repo) + monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) + _probe_result(refactor, monkeypatch, {"gemini": (1, "Authentication failed")}) + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: pytest.fail("認証確認より前に gh を呼んでいる"), + ) + with pytest.raises(SystemExit) as e: + refactor.cmd_init(_args(tmp_path)) + assert e.value.code == refactor.ABORT diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py index ccb44961..3074e0fa 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py @@ -383,24 +383,56 @@ def test_self_reported_values_cannot_pass_the_check( assert "テストが成功していません" in state["items"][0]["failure_reason"] -def test_failed_item_commits_are_reverted( - refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts -): - """検証に失敗した項目のコミットを Pull Request に残さない。 +def _drop_env(refactor, monkeypatch, revert_rc=0, pick_rc=0): + """取り消しと積み直しを実際には走らせず、順序と引数を記録する。 - 実装担当は項目ごとに push しているため、状態を `abandoned` にするだけでは - 差分が残り、以後のレビュー対象にも混入する。 + `git rev-parse HEAD` は**直前に積み直したコミット**に応じた値を返す。 + 積み直しで SHA が変わることを、状態の更新まで含めて確かめられるようにする。 """ + calls: list[list[str]] = [] + picked: list[str] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + rc = 0 + if cmd[:2] == ["git", "revert"] and "--abort" not in cmd: + rc = revert_rc + if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: + rc = pick_rc + if rc == 0: + picked.append(cmd[-1]) + return subprocess.CompletedProcess(cmd, rc, "", "conflict" if rc else "") + + def fake_git_out(work, args): + if args[:2] == ["rev-parse", "--verify"]: + return args[-1].replace("^{commit}", "") + if args == ["rev-parse", "HEAD"]: + return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + return "HEAD_BEFORE" + + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + monkeypatch.setattr(refactor, "_git_out", fake_git_out) + pushes: list[list[str]] = [] + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: pushes.append(list(cmd)) or "") + return calls, pushes + + +def _two_item_apply(tmp_path, env_tmp_dir, git_facts): + """1 件成功・1 件失敗の適用結果を用意する。失敗するのは R1-002。""" items = [item(item_id="R1-001"), item(item_id="R1-002")] state_path = _state_with_items(tmp_path, items) env_tmp_dir(state_path) - git_facts({ - "ok111": fact(sha="ok111"), - "bad111": fact(sha="bad111", diff_lines=400, - trailers=trailers(item_id="R1-002")), - "bad222": fact(sha="bad222", diff_lines=400, - trailers=trailers(item_id="R1-002")), - }) + git_facts( + { + "ok111": fact(sha="ok111"), + "bad111": fact(sha="bad111", diff_lines=400, + trailers=trailers(item_id="R1-002")), + "bad222": fact(sha="bad222", diff_lines=400, + trailers=trailers(item_id="R1-002")), + }, + # 履歴は bad222 が最も新しい + in_range=["bad222", "bad111", "ok111"], + ) write_result(state_path, "codex-apply-r1", { "base_sha": "aaa", "items": [ @@ -409,36 +441,181 @@ def test_failed_item_commits_are_reverted( {"item_id": "R1-002", "commits": [{"sha": "bad111"}, {"sha": "bad222"}]}, ], }) + return state_path - calls: list[list[str]] = [] - monkeypatch.setattr( - refactor.subprocess, "run", - lambda cmd, **kw: calls.append(list(cmd)) - or subprocess.CompletedProcess(cmd, 0, "", ""), - ) - pushes: list[list[str]] = [] - monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: pushes.append(cmd) or "") - # git の履歴は bad222 が最も新しい - monkeypatch.setattr( - refactor, "_git_out", - lambda work, args: ("bad222\nbad111\nok111" if args[:1] == ["rev-list"] - else args[-1].replace("^{commit}", "")), - ) + +def test_dropping_an_item_replays_the_kept_items( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """範囲を新しい順に全て戻し、残す項目を古い順に積み直すこと。 + + 失敗した項目のコミット**だけ**を戻すと、あとから同じ箇所を触った別項目の + コミットと必ず競合する。範囲全体の巻き戻しは履歴の逆再生なので競合しない。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, pushes = _drop_env(refactor, monkeypatch) refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) - reverts = [c for c in calls if c[:2] == ["git", "revert"]] - # 新しいコミットから順に戻す - assert [c[-1] for c in reverts] == ["bad222", "bad111"] + reverts = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + picks = [c[-1] for c in calls if c[:2] == ["git", "cherry-pick"]] + assert reverts == ["bad222", "bad111", "ok111"], "範囲を新しい順に全て戻していない" + assert picks == ["ok111"], "残す項目だけを積み直していない" assert pushes, "取り消し後に push していない" for cmd in pushes: - assert "--force" not in cmd + assert "--force" not in cmd and "--no-verify" not in cmd state = read_state(state_path) by_id = {i["item_id"]: i for i in state["items"]} assert by_id["R1-001"]["status"] == "reviewing" assert by_id["R1-002"]["status"] == "abandoned" - assert by_id["R1-002"]["commits"] == ["bad111", "bad222"] + assert by_id["R1-002"]["reverted"] is True + # 積み直しで SHA が変わるので、記録も追従すること + assert by_id["R1-001"]["commits"] == ["new-ok111"] + + +def test_replay_conflict_falls_back_to_whole_round( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """積み直せないときはラウンド全件の取り消しへ退避すること。 + + どの項目を残せるか決められない以上、半端な履歴を残すより全件捨てる方が安全。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, _ = _drop_env(refactor, monkeypatch, pick_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == 2, "全件失敗として次の提案ラウンドへ進むこと" + + assert ["git", "cherry-pick", "--abort"] in calls + assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + state = read_state(state_path) + assert all(i["status"] == "abandoned" for i in state["items"]) + assert state["rounds"][0]["apply"]["applied"] == [] + assert state["rounds"][0]["drops"][-1]["mode"] == "round" + + +def test_revert_failure_aborts_with_the_abort_code( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しに失敗したら「全件失敗」ではなく**中断**として終わること。 + + 2(全件失敗)と同じ扱いにすると、検証を通っていない変更を Pull Request に + 残したまま次の提案ラウンドが始まる。 + """ + _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == refactor.ABORT == 4 + assert ["git", "revert", "--abort"] in calls + assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + + +def test_progress_is_recorded_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しの前に判定を残すこと。中断しても到達点が状態から読める。""" + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + seen: list[list[dict]] = [] + + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + seen.append(read_state(state_path)["rounds"][0].get("apply_progress")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + assert seen, "取り消しが走っていない" + recorded = {p["item_id"]: p["result"] for p in seen[0]} + assert recorded == {"R1-001": "ok", "R1-002": "failed"} + + +def test_pending_push_is_marked_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しへ着手する前に再送信の印を立てること。 + + 取り消しは済んだのに push できずに終わると、検証を通っていない変更が + Pull Request に残り、次の実行は処理済みガードで素通りする。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + marks: list[bool] = [] + + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + marks.append(read_state(state_path)["rounds"][0].get("pending_push")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert marks and marks[0] is True + + +def test_pending_push_is_cleared_after_a_successful_push( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +def test_out_of_scope_commit_fails_the_item( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """指定した範囲の外を触ったコミットを検証で捕まえること。 + + 範囲を必須にした目的(提案の発散と変更の肥大を防ぐ)を、検証へ反映する。 + """ + items = [item(item_id="R1-001")] + state_path = _state_with_items(tmp_path, items) + env_tmp_dir(state_path) + git_facts({"out111": fact( + sha="out111", files=["src/foo.py", "dist/foo.py"], + )}) + write_result(state_path, "codex-apply-r1", { + "base_sha": "aaa", + "items": [{"item_id": "R1-001", "commits": [{"sha": "out111"}]}], + }) + _drop_env(refactor, monkeypatch) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == 2 + state = read_state(state_path) + assert state["items"][0]["out_of_scope"] is True + assert "dist/foo.py" in state["items"][0]["failure_reason"] + + +def test_scope_check_matches_only_on_path_prefix(refactor): + assert refactor.path_in_scope("src/foo.py", ["src"]) + assert refactor.path_in_scope("src", ["src"]) + assert not refactor.path_in_scope("src2/foo.py", ["src"]), "前方一致の取りこぼし" + assert not refactor.path_in_scope("dist/foo.py", ["src"]) + # 範囲が空なら検査しない(指定が無いのに全件落とさない) + assert refactor.out_of_scope_files({"files": ["any.py"]}, []) == [] def test_no_push_when_nothing_was_reverted( diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_proposals.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_proposals.py index 6e5ea1a3..e9882455 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_proposals.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_proposals.py @@ -185,9 +185,9 @@ def test_merge_proposals_command_creates_items( "durations": {}, "reviews": [], }]) env_tmp_dir(state_path) - write_result(state_path, "codex-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "gemini-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "kiro-propose-rf130", {"items": []}) + write_result(state_path, "codex-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "gemini-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": []}) refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -210,7 +210,7 @@ def test_merge_proposals_command_exits_2_when_nothing_adopted( }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": []}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": []}) with pytest.raises(SystemExit) as e: refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -234,9 +234,9 @@ def test_non_object_proposal_result_is_treated_as_empty( "durations": {}, "reviews": [], }]) env_tmp_dir(state_path) - write_result(state_path, "codex-propose-rf130", ["配列で返ってきた"]) - write_result(state_path, "gemini-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "kiro-propose-rf130", {"items": [proposal()]}) + write_result(state_path, "codex-propose-rf130-r1", ["配列で返ってきた"]) + write_result(state_path, "gemini-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": [proposal()]}) refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -259,7 +259,7 @@ def test_merge_proposals_is_idempotent(refactor, tmp_path, env_tmp_dir, no_git): }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": [proposal()]}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": [proposal()]}) args = type("A", (), {"id": 130})() refactor.cmd_merge_proposals(args) @@ -285,7 +285,7 @@ def test_merge_proposals_replays_the_converged_exit_code( }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": []}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": []}) args = type("A", (), {"id": 130})() for _ in range(2): diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_prepare_worktrees.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_prepare_worktrees.py index 08da12aa..14973eaa 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_prepare_worktrees.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_prepare_worktrees.py @@ -250,3 +250,35 @@ def test_empty_destination_is_provisioned(repo): dest.mkdir() _run(repo) assert (dest / "SKILL.md").is_file() + + +# ---------- gemini の読み取り除外 ---------- + +def test_gemini_gets_a_setting_that_allows_reading_the_provisioned_skills(repo): + """gemini は除外設定を**読み取りにも**適用するため、無効にする設定を置く。 + + 置かないと、配置した手順書を `read_file` で一切開けず、 + 語彙を読めないまま提案が語彙外になって全件降格する。 + """ + _run(repo) + settings = repo["root"] / "gemini" / ".gemini" / "settings.json" + assert settings.is_file(), "gemini の設定が置かれていない" + conf = json.loads(settings.read_text(encoding="utf-8")) + # 項目名は gemini の版で変わる。新旧どちらの形式でも書く + assert conf["context"]["fileFiltering"]["respectGitIgnore"] is False + assert conf["context"]["fileFiltering"]["respectGeminiIgnore"] is False + assert conf["fileFiltering"]["respectGitIgnore"] is False + assert conf["fileFiltering"]["respectGeminiIgnore"] is False + + +def test_gemini_settings_are_not_in_the_diff(repo): + _run(repo) + status = _git("status", "--short", cwd=repo["root"] / "gemini") + assert status.stdout.strip() == "", f"差分に現れている: {status.stdout}" + + +def test_only_gemini_gets_the_reading_setting(repo): + """他のランタイムの設定は触らない。""" + _run(repo) + for rt in ("codex", "kiro"): + assert not (repo["root"] / rt / ".gemini").exists() diff --git a/plugins/ndf-codex/.codex-plugin/plugin.json b/plugins/ndf-codex/.codex-plugin/plugin.json index b7a79dd4..f4c37365 100644 --- a/plugins/ndf-codex/.codex-plugin/plugin.json +++ b/plugins/ndf-codex/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "8.1.0", - "description": "Codex plugin (v8.1.0): 25 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/Gemini), and optional Slack completion notifications.", + "version": "8.2.0", + "description": "Codex plugin (v8.2.0): 25 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/Gemini), and optional Slack completion notifications.", "skills": "./skills/", "hooks": "./hooks/hooks.json" } diff --git a/plugins/ndf-codex/README.md b/plugins/ndf-codex/README.md index 3848418d..b4b0ed6c 100644 --- a/plugins/ndf-codex/README.md +++ b/plugins/ndf-codex/README.md @@ -47,7 +47,7 @@ Claude Code 専用の agents、statusline 自動設定、transcript retention ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/8.1.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/8.2.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -69,14 +69,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/8.1.0/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/8.2.0/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 8.1.0 +# => ndf@ai-plugins installed, enabled 8.2.0 ``` 抑止していない Skill(`markdown-writing` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf-codex/skills/cross-refactoring/SKILL.md b/plugins/ndf-codex/skills/cross-refactoring/SKILL.md index 202844bd..8f642f55 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf-codex/skills/cross-refactoring/SKILL.md @@ -37,7 +37,8 @@ allowed-tools: | 役割の分離 | 提案・レビューは**ホストを除く 3 者**、適用は**gemini を除く 3 者**。両者は重なるが一致しない | | レビューの単位 | **提案ラウンドの差分全体**に対して 1 回。項目ごとに回すと CLI 起動回数が採用件数に比例して膨らむ | | 収束しない項目 | **捨てる。** リファクタリングは任意の作業なので、揉める提案を Pull Request に残さない | -| 取り消しの単位 | **改善項目ごと。** 合意済みの項目は残す。そのために 1 手 1 コミットを機械検証する | +| 取り消しの単位 | **改善項目ごと(独立している範囲で)。** 範囲を新しい順に全て戻し、残す項目を積み直す。同一ファイルの隣接行を触る項目どうしは git だけでは分離できないため、そのときは**ラウンド全件へ退避する** | +| 範囲の扱い | `--scope` は**検証にも効く**。範囲外を触ったコミットを含む項目は失敗になる。生成物の同期は進行側が収束後にまとめて行う | | 検証の情報源 | **git と実際のテスト実行。** 結果ファイルの申告は検証に使わない(書き換えるだけで通る検査にしない) | | 投稿 | **AI 自身が `gh api` で投稿する。** ホストの作業文脈に差分やレビュー本文を載せない | | 状態の永続化 | `/.cross_refactoring/cross-refactoring-rf<番号>-state.json` に集約。中断・再開可能 | @@ -48,7 +49,7 @@ allowed-tools: | 引数 | 意味 | 既定 | | --- | --- | --- | | `[PR番号]` | 対象の Pull Request | 必須 | -| `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須** | 必須 | +| `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須。** 検証にも効くので、現状固定テストの置き場所も含める | 必須 | | `--host claude\|codex\|kiro` | ホストの明示指定。未指定時は環境変数から推定 | 推定 | | `--model RT=MODEL` | ランタイムごとのモデル。繰り返し指定できる | CLI の既定 | | `--baseline-test CMD` | 着手前と各コミットで実行するテスト。**振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須** | 必須 | @@ -59,7 +60,7 @@ allowed-tools: | `--test-timeout SEC` | テスト 1 回あたりの上限秒数。超えたら失敗として扱う | `900` | ```text -/ndf:cross-refactoring 130 --scope src/services --baseline-test "pytest -q" +/ndf:cross-refactoring 130 --scope src/services tests/services --baseline-test "pytest -q" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=opus-5 /ndf:cross-refactoring 130 --scope src --host codex --max-outer-rounds 1 ``` @@ -88,6 +89,20 @@ allowed-tools: ## 前提 - `gh` CLI が認証済みで、`jq` と `uv`(または Python 3.10 以上)が使える +- 参加する CLI が**すべてログイン済み**である。`init` が認証状態を確認し、1 つでも + 未認証なら中断する(未認証の CLI は起動から 15 秒で終わり、結果を残さないまま + 担当から脱落するため、確認しないと参加者が欠けた構成のまま進行する) + + | ランタイム | 確認コマンド | + | --- | --- | + | claude | `claude auth status` | + | codex | `codex login status` | + | gemini | `gemini --skip-trust -p ping --output-format text` | + | kiro | `kiro-cli whoami` | + + 確認コマンドは CLI の版で変わりうる。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` で + 飛ばせる(飛ばしたことは出力に残る) + - ホストごとに次の CLI が使える(不足していると初期化時に失敗する) | ホスト | 必要な CLI | @@ -139,7 +154,19 @@ PLUGIN_ROOT="${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" SCRIPTS="$PLUGIN_ROOT/skills/cross-refactoring/scripts" LIB="$PLUGIN_ROOT/skills/cross-review/scripts/lib" -eval "$("$SCRIPTS/refactor.py" init "$PR" --scope $SCOPE \ +# **中断(終了コード 4)は握り潰さない。** 取り消しに失敗した状態を「全件失敗」と +# 同じ扱いにすると、検証を通っていない変更を Pull Request に残したまま次の提案が +# 始まる。判定に使う終了コードだけを呼び出し側へ返し、それ以外は進行ごと止める。 +rf() { + "$SCRIPTS/refactor.py" "$@"; local rc=$? + if [ $rc -eq 4 ]; then + echo "❌ cross-refactoring を中断しました(refactor.py $1)" >&2 + exit 4 + fi + return $rc +} + +eval "$(rf init "$PR" --scope $SCOPE \ --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS)" @@ -147,18 +174,18 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" "$SCRIPTS/prepare-worktrees.sh" "$ID" while :; do # 提案ラウンドの繰り返し - eval "$("$SCRIPTS/refactor.py" start-round "$ID")" || break + eval "$(rf start-round "$ID")" || break # 終了コード 1 = 繰り返し終了 for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ - --stem-template '{agent}-propose-rf{id}' - "$SCRIPTS/refactor.py" merge-proposals "$ID" || break # 終了コード 2 = 採用 0 件 + --stem-template "{agent}-propose-rf{id}-r$ROUND" + rf merge-proposals "$ID" || break # 終了コード 2 = 採用 0 件 "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-apply-r$ROUND" --timeout 3600 - "$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" || continue # 全件失敗 + rf merge-apply "$ID" "$ROUND" || continue # 終了コード 2 = 全件失敗 # 適用後の状態をレビュー担当へ見せるため、読み取り用を同期する "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" @@ -169,23 +196,37 @@ while :; do # 提案ラウンドの繰り返 done "$LIB/monitor.py" "$ID" --agents "$REVIEWERS_CSV" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-review-r$ROUND" - "$SCRIPTS/refactor.py" judge-review "$ID" "$ROUND"; rc=$? + rf judge-review "$ID" "$ROUND"; rc=$? [ $rc -eq 0 ] && break # 2 者とも承認 [ $rc -eq 3 ] && continue # 形式不正 — 差し戻して再レビュー - if "$SCRIPTS/refactor.py" should-abandon "$ID" "$ROUND"; then - "$SCRIPTS/refactor.py" abandon-items "$ID" "$ROUND"; break + if rf should-abandon "$ID" "$ROUND"; then + rf abandon-items "$ID" "$ROUND"; break fi "$SCRIPTS/launch-cli.sh" "$IMPL" fix "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-fix-r$ROUND" - "$SCRIPTS/refactor.py" merge-fix "$ID" "$ROUND" + rf merge-fix "$ID" "$ROUND" # 修正後の状態を再レビューさせる。同期しないと古い差分を評価してしまう "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" done - "$SCRIPTS/refactor.py" advance "$ID" || break + rf advance "$ID" || break done + +# 収束後にまとめて生成物を同期する(**進行側の責務**)。編集元から配布物を生成する +# 規約を持つリポジトリでは、実装担当に同期させると範囲外の変更が生まれる。 +# 同期が要るなら、ここで生成してから Step 7 の最終ゲートへ渡す。 ``` +### 終了コード + +| コード | 意味 | 進行 | +| --- | --- | --- | +| 0 | 正常 | 続ける | +| 1 | 繰り返しの終了(`start-round` / `advance`) | 抜ける | +| 2 | 判定の結果(採用 0 件 / 全件失敗 / 変更要求 など) | 各コマンドの表に従う | +| 3 | レビュー結果の形式不正 | 差し戻して再レビュー | +| **4** | **中断**(取り消しの失敗、認証切れ、範囲を確定できないなど) | **進行ごと止める** | + 続けて **Step 7** で `/ndf:cross-review ` を実行する。レビューはラウンド単位なので、 **ラウンドを跨いだ整合はここで見る**。収束したら Draft を解除し、 `refactor.py report "$ID" --metrics` の出力を報告する。 @@ -199,6 +240,8 @@ done | ホストのサブエージェントで適用する | ホストの作業文脈に差分が載り、実装者とレビュー担当の独立性が崩れる | | `launch-cli.sh` に「ホストなら起動しない」分岐を入れる | ホストは適用担当として起動しうる。分岐はランタイム名だけで行う | | `--scope` を省く | 提案が発散し、Pull Request が肥大する | +| 実装担当に生成物を同期させる | 範囲外の変更が生まれ、差分予算を超える。同期は進行側が収束後にまとめて行う | +| 取り消しの失敗を「全件失敗」として次のラウンドへ進む | 検証を通っていない変更が Pull Request に残る。終了コード 4 は必ず進行ごと止める | | `--dry-run` の出力を実行結果と混同する | 確認用なので git も状態ファイルも触らない。進行は 1 歩も進まない | | 複数の改善項目を 1 コミットにまとめる | 取り消し範囲が項目単位で決まらなくなる。適用結果の検証で失敗になる | | 結果ファイルの申告を検証の材料にする | 実装担当は報告する側。JSON を書き換えるだけで通る検査は機械検証ではない | diff --git a/plugins/ndf-codex/skills/cross-refactoring/docs/01-state-and-propose.md b/plugins/ndf-codex/skills/cross-refactoring/docs/01-state-and-propose.md index bfbb6735..487a9b4c 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/docs/01-state-and-propose.md +++ b/plugins/ndf-codex/skills/cross-refactoring/docs/01-state-and-propose.md @@ -37,7 +37,16 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 使うと古い HEAD に対して提案・適用してしまう。早送りできない(履歴が分かれた) ときは中断する。`git fetch` に失敗したときも中断する(古い `origin/` へ 早送りして「同期したつもり」になるのを防ぐ) -5. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 +5. **認証状態の確認** — 参加する CLI を 1 つずつ確認し、未認証なら**初期化ごと中断する** + (終了コード 4)。存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、 + 結果ファイルを残さないまま担当から脱落するが、それでも初期化は成功として扱われるため、 + **参加者が 1 人欠けた構成のまま最後まで進んでしまう**(実測)。作業ディレクトリを + 作る前に確認する。確認コマンドは CLI の版で変わりうるので `NDF_SKIP_AUTH_CHECK=1` + で飛ばせるが、飛ばしたことは必ず出力へ残す +6. **語彙の受け渡し** — 検証側が持つスメル・手法・重要度の集合を状態ファイルの + `vocabulary` へ書く。提案プロンプトはここから**許容値をそのまま列挙する**。 + 定義を 1 箇所に保ったまま、読ませ方の不確実性を減らすためである +7. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 壊れた状態から始めると、壊したのか元から壊れていたのか区別できない。 この引数は**必須**である。振る舞いが変わっていないことを示す手段が無い書き換えは、 `refactoring` Skill の定義からして構造改善ではない @@ -121,6 +130,30 @@ gemini は NDF の配布先ではないため「標準の配置先」を持た **対象リポジトリ本体を書き換える**ことになり、「配置は作業ディレクトリの中だけで完結 させる」という前提を破る。 +#### gemini は除外を読み取りにも適用する + +全件無視をそのまま効かせると、**gemini は配置した手順書を 1 つも開けない**。 + +```text +Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +is ignored by configured ignore patterns. +``` + +手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と +書いている前提が崩れるため、gemini の作業ディレクトリへ +`.gemini/settings.json` を置き、**読み取り側の除外だけを無効にする**。 + +```json +{ + "context": { "fileFiltering": { "respectGitIgnore": false, "respectGeminiIgnore": false } }, + "fileFiltering": { "respectGitIgnore": false, "respectGeminiIgnore": false } +} +``` + +設定の項目名は gemini の版で変わるため、**新旧どちらの形式でも書く**(0.55.1 で +読み取り成功を確認)。設定自身も差分に出さないよう `.gemini/` ごと無視する。 +配置は**手順書を配る前**に行う。gemini は起動時に 1 度だけ設定を読むためである。 + ### 読ませ方(明示パスを必ず書く) Skill を配置しても、**本文を読むかどうかはランタイムによって違う**。 @@ -150,7 +183,7 @@ for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ - --stem-template '{agent}-propose-rf{id}' + --stem-template "{agent}-propose-rf{id}-r$ROUND" ``` 3 CLI を並列で起動し、同一のプロンプトで提案させる。**提案フェーズにホストは現れない** @@ -159,6 +192,29 @@ done 提出形式は [prompts/propose.md](../prompts/propose.md) にある。 +### 結果ファイル名にラウンド番号を入れる + +CLI の起動時に同名の結果ファイルを消すため、**提案の結果ファイル名にもラウンド番号が +要る**。番号を入れないと、2 巡目の提案が始まった時点で 1 巡目の提案内容が失われる。 +統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは復元できない**。 + +| フェーズ | ファイル名 | +| --- | --- | +| 提案 | `<ランタイム>-propose-rf-r<ラウンド>-result.json` | +| 適用 | `<ランタイム>-apply-r<ラウンド>-result.json` | +| レビュー | `<ランタイム>-review-r<ラウンド>-result.json` | + +### 語彙は列挙して渡す + +提案プロンプトは `smell` / `technique` / `severity` の**許容値を列挙する**。 +「手順書の語彙に限定する」とだけ書くと、手順書の見出しが日本語であるため、読んだ側が +日本語を語彙と解釈する。実測では gemini の提案 4 件が全て日本語の語彙で返り、 +内容は妥当だったにもかかわらず語彙外の降格規則で全件が見送りになった。 + +列挙の元は `refactor.py` の `SMELLS` / `TECHNIQUES`(検証側が持つ集合)である。 +`init` が状態ファイルの `vocabulary` へ書き、`launch-cli.sh` が読んで差し込む。 +**同じ一覧を 2 か所に書かない。** + ## Step 3: 提案のマージ ```bash diff --git a/plugins/ndf-codex/skills/cross-refactoring/docs/02-apply-and-review.md b/plugins/ndf-codex/skills/cross-refactoring/docs/02-apply-and-review.md index 93146ae1..e9b460b8 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/docs/02-apply-and-review.md +++ b/plugins/ndf-codex/skills/cross-refactoring/docs/02-apply-and-review.md @@ -8,9 +8,13 @@ "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-apply-r$ROUND" --timeout 3600 -"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 終了コード 2 = 全件失敗 +"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 2 = 全件失敗 / 4 = 中断 ``` +終了コード 2 と 4 を**必ず区別する**。同じ扱いにすると、取り消しに失敗した状態を +「全件失敗」として次の提案ラウンドへ進み、検証を通っていない変更が Pull Request に +残ったまま新しい提案が始まる(実測)。 + 実装担当を**1 ラウンド 1 回**起動し、採用した改善項目を優先度順に**直列適用**させる。 並列適用はしない(同一ブランチへの同時コミットは競合とレビュー単位の曖昧化を招く)。 @@ -36,8 +40,38 @@ | テストが無い経路は先に現状固定テスト | `git show --name-only` | `test_gap` が真の項目は、先頭コミットがテストの置き場所を触っている | | 項目の分離 | git のトレーラー | 各コミットの `Item-Id` がその項目と一致する。複数の項目を 1 コミットにまとめたら失敗 | | 差分予算 | `git show --numstat` | 実差分の合計が `estimated_diff_lines` の 2 倍を超えたら失敗(範囲の逸脱) | +| 対象範囲の遵守 | `git show --name-only` | 触ったファイルが全て `--scope` の中にある。1 つでも外なら失敗 | | 機能変更の混入なし | — | 機械判定は不可能。レビュー観点に委ねる | +#### 範囲の指定は検証にも効かせる + +`--scope` を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を検証へ +反映しないと目的を果たせない。実測では、編集元から配布物を生成する規約に従った結果として +範囲外の 3 系統が変更され、差分が 4 倍に膨らんで差分予算を超えた。実装担当の判断自体は +リポジトリの規約に沿っており、**規約と範囲の指定が衝突していた**のが原因である。 + +そこで責務を分ける。 + +| 誰が | 何を | +| --- | --- | +| 実装担当 | `--scope` の中だけを変更する。生成物・配布物の同期はしない | +| 進行側(ホスト) | 収束後にまとめて生成物を同期する | + +判定は**前方一致だけ**で行い、除外規則は持たない。規則を書けるようにすると、 +規則を 1 行足すだけで範囲の検査を骨抜きにできる。 + +そのため **`--scope` には現状固定テストの置き場所も含める**。含めないと、 +`test_gap` が真の項目で「テストを先に足せ」と「範囲外を触るな」が両立しなくなり、 +その項目は必ず失敗する。 + +```bash +# ❌ テストの置き場所が入っていない +--scope src/services + +# ✅ 直す対象とテストの置き場所を両方入れる +--scope src/services tests/services +``` + テストの実行はコミットごとに `git checkout --detach ` して行い、終わったら 必ず元のブランチへ戻す。1 ラウンドの採用上限があるため実行回数は数回に収まり、 CLI の起動コストに比べれば無視できる。 @@ -101,9 +135,28 @@ Pull Request に残る。**都合の悪い変更を申告しないだけで検 **1 件の失敗でラウンドを止めない。** 失敗した項目だけを見送りにして、残りは採用する。 全件失敗のときだけ終了コード 2 を返し、次の提案ラウンドへ進む。 -**失敗した項目のコミットはその場で取り消して push する。** 実装担当は項目ごとに -push しているため、状態を `abandoned` にするだけでは差分が Pull Request に残り、 -以後のレビュー対象にも混入する。何が消えるかを先に見たいときは `--dry-run` を付ける。 +#### 判定はその都度記録する + +**項目ごとの判定が出るたびに状態ファイルへ保存する。** まとめて最後に保存すると、 +取り消しの途中で中断したときに適用の記録が一切残らない。実測では 14 件の適用コミットと +3 件の取り消しコミットが実在するのに、状態ファイルは全項目 `pending` / コミット 0 件の +ままだった。**どのコミットが検証を通ったのかを状態から復元できず、同じ手順を叩き直しても +再開できない。** 再開可能性は収束ループの前提なので、ここが崩れると復帰手段が無くなる。 + +記録先は `rounds[].apply_progress`(項目 ID / 判定 / 理由 / コミット)である。 + +#### 取り消しは判定が出そろってからまとめて行う + +**失敗した項目のコミットを Pull Request に残さない。** 実装担当は項目ごとに push して +いるため、状態を `abandoned` にするだけでは差分が残り、以後のレビュー対象にも混入する。 +何が消えるかを先に見たいときは `--dry-run` を付ける。 + +ただし**項目ごとにその場で戻してはならない**。詳細は +[取り消しは巻き戻して積み直す](#取り消しは巻き戻して積み直す)を参照する。 + +**取り消しへ着手する前に `pending_push` を立てる。** 取り消しは済んだのに push できずに +終わると、Pull Request 側には未検証の差分が残るのに、次の実行は処理済みガードで +素通りしてしまう。印があれば、次の実行が判定より先に再送信する。 ### コミットトレーラーの形式 @@ -255,10 +308,10 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 修正ラウンドが上限に達したら、**未解決の指摘が紐づく改善項目だけ**を取り消す。 指摘の無い項目と解決済みの項目は Pull Request に残す。 -- 取り消しは**git の履歴から新しい順に並べ直して** `git revert` する。申告された - 順序は信用しない。古いコミットから戻すと後続の取り消しが競合して止まる +- 取り消しは**範囲を新しい順に全て戻してから、残す項目を積み直す**(次節) - 取り消しに失敗したら、**着手前の HEAD まで戻してから**中断する。先行して成功した - 取り消しだけが履歴に残ると、再実行で不整合になって進めなくなる + 取り消しだけが履歴に残ると、再実行で不整合になって進めなくなる。中断は + **終了コード 4** で表し、「全件失敗」(2)と区別する - **保存してから push する。** 逆順にすると、push の失敗時に取り消しはローカルへ 残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう - push の前に `pending_push` を立て、成功したら消す。失敗したまま終わっても、 @@ -275,6 +328,63 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 修正は必須だが、リファクタリングは任意の作業なので、揉める提案を Pull Request に 残さない方が安全である。 +### 取り消しは巻き戻して積み直す + +**項目のコミットだけを新しい順に戻す方法では足りない。** その並べ替えは同じ項目に属する +コミットの中でしか働かず、取り消し対象より新しい**別項目**のコミットが同じ箇所を触って +いると必ず競合する。実測では、採用した 5 件のうち 4 件が同一ファイルの隣接領域を変更して +おり、取り消しが競合して進行が止まった。 + +```text +❌ R1-002 のコミット ea3209c を取り消せませんでした: error: could not revert ea3209c... +``` + +そこで次の順で行う。 + +```mermaid +flowchart LR + A["範囲 base..HEAD を
新しい順に全て revert"] --> B["残す項目のコミットを
古い順に cherry-pick"] + B -->|成功| C["項目単位の取り消し完了"]:::ok + B -->|競合| D["着手前 HEAD へ reset
範囲を全て revert"] --> E["ラウンド全件を取り消し"]:::stop + + classDef ok fill:#dfd,stroke:#383 + classDef stop fill:#fdd,stroke:#933 +``` + +- 範囲全体を新しい順にたどる取り消しは**履歴の逆再生**なので競合しない。競合するのは + 「一部のコミットだけを飛ばして戻す」ときである +- 積み直しの対象は**残す項目に属するコミットだけ**。過去の取り消しコミットのように + どの項目にも属さないものは積み直さない +- **積み直しで SHA が変わる。** 状態ファイルの `items[].commits` を新しい SHA へ + 更新する。更新しないと、次の取り消しが履歴に無い SHA を指す +- `git push --force` は使わない。履歴の書き換えではなく、**revert と cherry-pick による + 前進だけ**で行う + +#### 隣接する変更は分離できない + +**項目単位で取り消せるのは、項目どうしの変更が独立しているときだけである。** +取り消す側と残す側が同一ファイルの隣接行を触っていると、積み直しの +`git cherry-pick` も競合する。取り消した側の行が消えることで、残す側のパッチが +前提にしている文脈も消えるためで、これは git だけでは決められない。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル | 項目単位 | +| 同一ファイルの離れた行 | 項目単位 | +| 同一ファイルの隣接行 | **ラウンド全件へ退避** | + +退避したときは着手前の状態まで戻し、ラウンドの全項目を見送る。半端な履歴を残すより、 +決定的な状態へ落とす方が安全である。退避したことは `rounds[].drops[].mode` に +`round` として残るので、頻度は報告から読める。 + +実測(Pull Request #118)では採用 5 件のうち 4 件が同一ファイルの隣接領域を変更して +いた。**この構成では退避が普通に起こる**と見込んでおく。項目単位を保ちたいなら、 +`--max-items-per-round` を下げるか、`--scope` を狭めて 1 ラウンドで同じファイルの +近い場所を複数触らせないようにする。 + +範囲の起点を記録していない状態ファイル(旧版)では積み直せないため、従来どおり +項目のコミットだけを新しい順に戻す。この経路では取り消し自体が競合しうる。 + ## Step 7: 提案ラウンドの収束と最終ゲート ```bash @@ -292,7 +402,11 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 重複率は `path` + `symbol` + `smell` の集合比較で求める。同じ提案が毎ラウンド出続けて 終わらない状態を検知するためである。 -終了後は **`/ndf:cross-review `** で Pull Request 全体を承認収束にかける。 +終了後、生成物の同期が要るリポジトリでは**ここで進行側がまとめて同期する**。 +実装担当に同期させると範囲外の変更が生まれ、差分予算にも影響する(Step 4 の +「範囲の指定は検証にも効かせる」を参照)。 + +続けて **`/ndf:cross-review `** で Pull Request 全体を承認収束にかける。 レビューはラウンド単位なので、**ラウンドを跨いだ整合はここで見る**。 ## Step 8: 報告 diff --git a/plugins/ndf-codex/skills/cross-refactoring/prompts/apply.md b/plugins/ndf-codex/skills/cross-refactoring/prompts/apply.md index 0db2b60f..ad7fac79 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/prompts/apply.md +++ b/plugins/ndf-codex/skills/cross-refactoring/prompts/apply.md @@ -56,6 +56,11 @@ Impl-Model: $RF_MODEL - **`git push --force` と `--no-verify` を使わない** - 作業ディレクトリの外を触らない +- **対象範囲(`$RF_SCOPE`)の外にあるファイルを 1 つも変更しない。** 範囲外を触った + コミットを含む項目は検証で失敗し、取り消されます +- **生成物・配布物の同期をしない。** このリポジトリに「編集元から配布物を生成する」 + 規約があっても、同期は**進行側が収束後にまとめて行う**責務です。ここで同期すると + 範囲外の変更が生まれ、差分予算も超えます - **機能変更を混ぜない。** 振る舞いを変える修正が必要だと分かったら、その項目は 適用せず `status` を `skipped` にして理由を書く - 提案された手順の範囲を超えない。ついでの整理をしない diff --git a/plugins/ndf-codex/skills/cross-refactoring/prompts/fix.md b/plugins/ndf-codex/skills/cross-refactoring/prompts/fix.md index df2814e7..b28b967d 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/prompts/fix.md +++ b/plugins/ndf-codex/skills/cross-refactoring/prompts/fix.md @@ -57,6 +57,9 @@ Impl-Model: $RF_MODEL - **`git push --force` と `--no-verify` を使わない** - 作業ディレクトリの外を触らない +- **対象範囲(`$RF_SCOPE`)の外にあるファイルを 1 つも変更しない。** 範囲外を触った + 修正コミットがあると、その修正ラウンドの範囲ごと取り消されます +- **生成物・配布物の同期をしない。** 同期は進行側が収束後にまとめて行います - 指摘に無い箇所を「ついでに」直さない。ラウンドの差分が膨らみ、 どの変更がどの指摘に対応するのか追えなくなる diff --git a/plugins/ndf-codex/skills/cross-refactoring/prompts/propose.md b/plugins/ndf-codex/skills/cross-refactoring/prompts/propose.md index 2127e31b..cc299fee 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/prompts/propose.md +++ b/plugins/ndf-codex/skills/cross-refactoring/prompts/propose.md @@ -20,6 +20,24 @@ $RF_SKILL_BLOCK $RF_EXCLUDED +## 語彙(この値だけを使う) + +`smell` と `technique` には**下の識別子をそのまま**書いてください。日本語の説明は +意味の対応を示すためのもので、**出力に日本語を書くと語彙外**として扱われ、 +自動的に最低の重要度へ降格し、しきい値で落ちます。 + +### `smell` に書ける値 + +$RF_VOCAB_SMELLS + +### `technique` に書ける値 + +$RF_VOCAB_TECHNIQUES + +### `severity` に書ける値 + +$RF_VOCAB_SEVERITIES + ## やること 1. 対象範囲のコードを読み、**調べる価値がある兆候**を探す @@ -54,9 +72,8 @@ $RF_EXCLUDED ``` - `path` はリポジトリ相対、`symbol` は関数・メソッド・クラスの名前 -- `smell` と `technique` は**手順書の語彙に限定**する。語彙外の値は自動で最低の - 重要度へ降格され、しきい値で落ちます -- `severity` は `critical` / `major` / `minor` のいずれか +- `smell` / `technique` / `severity` は**上に列挙した識別子のいずれか**。 + 列挙にない値(日本語を含む)は自動で最低の重要度へ降格され、しきい値で落ちます - `path` + `symbol` + `smell` が同じ提案は 1 件へ統合されます。**他のランタイムと 合意した提案ほど優先される**ので、独自性を狙わず素直に挙げてください diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/launch-cli.sh b/plugins/ndf-codex/skills/cross-refactoring/scripts/launch-cli.sh index 1f221e20..c2d7468c 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/launch-cli.sh +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/launch-cli.sh @@ -45,7 +45,10 @@ MAX_ITEMS=$(jq -r '.max_items_per_round' "$STATE") case "$PHASE" in propose) - STEM=$TMP_DIR/$RUNTIME-propose-rf$ID + # **提案にもラウンド番号を入れる。** 起動時に同名の結果ファイルを消すため、 + # 番号が無いと 2 巡目の提案が 1 巡目の内容を消してしまう。 + [ "$ROUND" -ge 1 ] 2>/dev/null || { echo "propose には ROUND が必要です" >&2; exit 1; } + STEM=$TMP_DIR/$RUNTIME-propose-rf$ID-r$ROUND WORKDIR=$ROOT/$RUNTIME ;; apply|fix) @@ -112,7 +115,21 @@ export RF_MODEL=${MODEL:-default} RF_WORKDIR=$WORKDIR RF_STEM=$STEM export RF_SCOPE=$SCOPE RF_HEAD_BRANCH=$HEAD_BRANCH RF_BASE_BRANCH=$BASE_BRANCH export RF_BASELINE_TEST=$BASELINE_TEST RF_MAX_ITEMS=$MAX_ITEMS export RF_SKILL_BLOCK=$SKILL_BLOCK RF_EXCLUDED=$EXCLUDED + +# 語彙の許容値。**手順書を読ませるだけでは足りない。** 手順書の見出しは日本語なので、 +# 「語彙に限定する」とだけ書くと読んだ側が日本語を語彙と解釈し、語彙外の降格規則で +# 全件が見送りになる(実測)。検証側が持つ集合を状態ファイル経由で受け取り、 +# **許容値をそのまま列挙する**。 +VOCAB_SMELLS=$(jq -r '(.vocabulary.smells // {}) | to_entries[] | "- `\(.key)` — \(.value)"' "$STATE") +VOCAB_TECHNIQUES=$(jq -r '(.vocabulary.techniques // {}) | to_entries[] | "- `\(.key)` — \(.value)"' "$STATE") +VOCAB_SEVERITIES=$(jq -r '(.vocabulary.severities // []) | map("`" + . + "`") | join(" / ")' "$STATE") +[ -n "$VOCAB_SMELLS" ] || VOCAB_SMELLS="(状態ファイルに語彙がありません。手順書の語彙に従うこと)" +[ -n "$VOCAB_TECHNIQUES" ] || VOCAB_TECHNIQUES="(同上)" +[ -n "$VOCAB_SEVERITIES" ] || VOCAB_SEVERITIES="\`critical\` / \`major\` / \`minor\`" + export RF_ITEMS=$ITEMS_JSON RF_TMP_DIR=$TMP_DIR +export RF_VOCAB_SMELLS=$VOCAB_SMELLS RF_VOCAB_TECHNIQUES=$VOCAB_TECHNIQUES +export RF_VOCAB_SEVERITIES=$VOCAB_SEVERITIES # 雛形は `${RF_*}` を展開するだけの素の Markdown。コマンド置換は展開しない # (プロンプト本文に `$(...)` や backtick が現れても実行させないため)。 diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/prepare-worktrees.sh b/plugins/ndf-codex/skills/cross-refactoring/scripts/prepare-worktrees.sh index ea0b05ce..c13afe38 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/prepare-worktrees.sh +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/prepare-worktrees.sh @@ -131,9 +131,41 @@ provision_skill() { # 代わりに、配置したディレクトリ自身へ全件無視の `.gitignore` を置く。自分自身も # 無視されるため差分に現れず、他の未追跡ファイルには影響しない。 ignore_dir() { + mkdir -p "$1" printf '*\n' > "$1/.gitignore" } +# gemini は**除外設定を読み取りにも適用する**。上の全件無視をそのまま効かせると、 +# 配置した手順書を `read_file` で一切開けない。 +# +# Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +# is ignored by configured ignore patterns. +# +# 手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と +# 書いている前提が崩れるため、**作業ディレクトリ限定で**読み取り側の除外を無効にする。 +# 設定の項目名は gemini の版で変わるので、新旧どちらの形式でも書く(0.55.1 で確認)。 +configure_gemini_reading() { + local base=$1 + local dir="$base/.gemini" + mkdir -p "$dir" + cat > "$dir/settings.json" <<'GEMINI_SETTINGS_EOF' +{ + "context": { + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } + }, + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } +} +GEMINI_SETTINGS_EOF + # 設定そのものも差分に出さない。`.gemini/` ごと無視する。 + printf '*\n' > "$dir/.gitignore" +} + HEAD_SHA=$(git rev-parse "origin/$HEAD_BRANCH" 2>/dev/null || git -C "$WORK" rev-parse HEAD) @@ -160,6 +192,9 @@ MISSING=() CONFLICT=() for rt in "${RUNTIMES[@]}"; do + # 配置より先に置く。gemini は起動時に 1 度だけ設定を読むため、 + # 手順書を配ってから設定を書いても間に合う保証がない。 + [ "$rt" = "gemini" ] && configure_gemini_reading "$ROOT/$rt" entry='{}' for name in "${REQUIRED_SKILLS[@]}"; do status=$(provision_skill "$ROOT/$rt" "$rt" "$name") diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 082dc773..74a99d3e 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -48,9 +48,19 @@ import models as models_lib # noqa: E402 import statefile # noqa: E402 -die = statefile.die info = statefile.info +# 中断の終了コード。**「全件失敗」(2)と区別する。** 進行スクリプトは 2 なら次の +# 提案ラウンドへ進み、4 なら進行そのものを止める。区別しないと、取り消しに失敗した +# 状態を「全件失敗」として握り潰し、**検証を通っていない変更を Pull Request に +# 残したまま**次の提案が始まる(実測)。 +ABORT = 4 + + +def die(msg: str, code: int = ABORT) -> None: + """中断して終了する。既定は「中断」を表す終了コード。""" + statefile.die(msg, code) + # ---------------- 語彙 ---------------- # スメルと手法の語彙は `refactoring` Skill の references と 1 対 1 で対応させる。 @@ -101,6 +111,25 @@ SEVERITY_ORDER = {"unknown": 0, "minor": 1, "major": 2, "critical": 3} DEFAULT_SEVERITY_THRESHOLD = "minor" +# 提案が名乗ってよい重要度。`unknown` は降格先なので含めない。 +SEVERITIES: tuple[str, ...] = tuple(s for s in SEVERITY_ORDER if s != "unknown") + + +def vocabulary() -> dict[str, Any]: + """提案プロンプトへ**そのまま列挙する**ための語彙集合。 + + 手順書の見出しは日本語なので、「語彙に限定する」とだけ書くと読んだ側が + 日本語を語彙と解釈する(実測では gemini の提案 4 件が全て日本語で返り、 + 語彙外の降格規則により全件見送りになった)。**検証側が持つ集合をそのまま + 渡す**ことで、許容値の定義を 1 箇所に保ったまま列挙できる。 + """ + return { + "smells": dict(SMELLS), + "techniques": dict(TECHNIQUES), + "severities": list(SEVERITIES), + } + + # 適用と修正のコミットに必須のトレーラー。1 つでも欠けたら当該項目を失敗にする。 # 自由文で「codex が実装」と書かせると集計に使えないため、必ずトレーラー形式にする。 REQUIRED_TRAILERS = ("Item-Id", "Round", "Impl-Runtime", "Impl-Model") @@ -122,6 +151,25 @@ # 差し戻しを無限に繰り返すと、形式を満たせないランタイムでループが止まらなくなる。 MAX_INVALID_REVIEWS = 1 +# 認証状態の確認コマンド。**CLI の存在確認だけでは足りない。** 未認証の CLI は +# 起動から 15 秒で終わり、結果ファイルを残さないまま担当から脱落する(実測)。 +# それでも初期化は成功として扱われるため、参加者が 1 人欠けた構成のまま進行する。 +AUTH_PROBES: dict[str, tuple[str, ...]] = { + "claude": ("claude", "auth", "status"), + "codex": ("codex", "login", "status"), + # gemini には認証確認の副コマンドが無い。最小のプロンプトで疎通を見る。 + # 作業ディレクトリの信頼判定に引っ掛からないよう `--skip-trust` を付ける。 + "gemini": ("gemini", "--skip-trust", "-p", "ping", "--output-format", "text"), + "kiro": ("kiro-cli", "whoami"), +} +AUTH_PROBE_TIMEOUT = 120 + +# **終了コード 0 でも未認証を示すことがある。** kiro は成否を終了コードで表さない。 +UNAUTHENTICATED_MARKERS = ( + "not logged in", "not authenticated", "authentication failed", + "login required", "unauthorized", "please log in", +) + # ---------------- パス解決 ---------------- @@ -203,9 +251,15 @@ def _result_path(state: dict[str, Any], runtime: str, stem: str) -> pathlib.Path def stem_for(runtime: str, phase: str, state_id: int, round_no: Optional[int] = None) -> str: - """一時ファイル名の骨格。監視スクリプトの `--stem-template` と揃える。""" + """一時ファイル名の骨格。監視スクリプトの `--stem-template` と揃える。 + + **提案にもラウンド番号を入れる。** CLI の起動時に同名の結果ファイルを消すため、 + 番号が無いと 2 巡目の提案が始まった時点で 1 巡目の提案内容が失われる。 + 統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは + 復元できなくなる**(実測)。 + """ if phase == "propose": - return f"{runtime}-propose-rf{state_id}" + return f"{runtime}-propose-rf{state_id}-r{round_no}" return f"{runtime}-{phase}-r{round_no}" @@ -364,6 +418,51 @@ def duplicate_rate( # **git と実際のテスト実行**から取る。結果ファイルから使うのは「どのコミットが # どの項目のものか」という対応付けの手がかりだけである。 +def path_in_scope(path: str, scope: Iterable[str]) -> bool: + """`path` が対象範囲の中にあるか。判定は**前方一致だけ**で行う。 + + 除外規則を足さない。規則を書けるようにすると、規則を 1 行足すだけで + 範囲の検査を骨抜きにできてしまう。 + """ + for entry in scope: + prefix = str(entry).strip().rstrip("/") + if not prefix: + continue + if path == prefix or path.startswith(prefix + "/"): + return True + return False + + +def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: + """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" + paths = list(scope) + if not paths: + return [] + return sorted( + p for p in (commit.get("files") or []) if not path_in_scope(p, paths) + ) + + +def verify_scope(commit: dict[str, Any], scope: Iterable[str]) -> Optional[str]: + """対象範囲の外を触っていれば理由を返す。 + + 範囲を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を + 検証に反映しないと目的を果たせない。実測では、生成物を同期する規約に従った + 結果として範囲外が 3 系統変更され、差分が 4 倍に膨らんで差分予算を超えた。 + 生成物の同期が要る構成では、**同期は進行側の責務**として分離する。 + """ + outside = out_of_scope_files(commit, scope) + if not outside: + return None + shown = ", ".join(outside[:5]) + more = f" ほか {len(outside) - 5} 件" if len(outside) > 5 else "" + return ( + f"コミット {commit.get('sha', '?')} が対象範囲の外を変更しています" + f"({shown}{more})。生成物の同期は進行側が収束後にまとめて行います。" + "現状固定テストの置き場所が範囲外なら、`--scope` に含めてから実行してください" + ) + + def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: """コミットのトレーラーが 4 つ揃っているか。欠けていれば理由を返す。 @@ -377,7 +476,9 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という @@ -386,6 +487,9 @@ def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: if not commit.get("exists", True): return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -397,7 +501,8 @@ def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: def verify_apply_item( - item: dict[str, Any], facts: list[dict[str, Any]] + item: dict[str, Any], facts: list[dict[str, Any]], + scope: Optional[Iterable[str]] = None, ) -> Optional[str]: """1 項目の適用結果を検証する。問題があれば失敗理由を返す。 @@ -415,6 +520,9 @@ def verify_apply_item( "(申告だけで実体がありません)" ) problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -531,6 +639,56 @@ def unresolved_item_ids( # ---------------- サブコマンド ---------------- +def check_auth(runtimes: Iterable[str]) -> dict[str, dict[str, Any]]: + """参加する CLI の認証状態を確かめる。1 つでも欠けたら初期化を中断する。 + + 存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、結果ファイルを + 残さないまま提案・レビューの担当から脱落するが、**初期化は成功として扱われる** + ため、参加者が 1 人欠けた構成のまま最後まで進んでしまう。 + + 確認コマンドは CLI の版で変わりうるので、`NDF_SKIP_AUTH_CHECK` で飛ばせるように + しておく。飛ばしたことは必ず出力へ残す(黙って劣化させない)。 + """ + if os.environ.get("NDF_SKIP_AUTH_CHECK"): + info("⚠ NDF_SKIP_AUTH_CHECK が設定されているため認証確認を飛ばしました") + return {} + + results: dict[str, dict[str, Any]] = {} + failed: list[str] = [] + for runtime in runtimes: + probe = AUTH_PROBES.get(runtime) + if probe is None: + continue + env = dict(os.environ) + if runtime == "gemini": + # 新規パスは untrusted と判定されるため、確認でも信頼を明示する。 + env["GEMINI_CLI_TRUST_WORKSPACE"] = "true" + try: + r = subprocess.run(list(probe), capture_output=True, text=True, + timeout=AUTH_PROBE_TIMEOUT, env=env) + merged = f"{r.stdout}\n{r.stderr}".lower() + ok = r.returncode == 0 and not any( + m in merged for m in UNAUTHENTICATED_MARKERS + ) + detail = (r.stderr.strip() or r.stdout.strip())[:200] + except FileNotFoundError: + ok, detail = False, "コマンドが見つかりません" + except subprocess.TimeoutExpired: + ok, detail = False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" + results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} + info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") + if not ok: + failed.append(f"{runtime}({detail})") + + if failed: + die( + "認証されていない CLI があります: " + " / ".join(failed) + "。" + "参加者が欠けたまま進むと、その者の提案とレビューが無いまま収束します。" + "各 CLI でログインしてから再実行してください" + ) + return results + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -553,6 +711,10 @@ def cmd_init(args: argparse.Namespace) -> None: if host in runtimes: die(f"提案・レビューの母集合にホスト {host} が含まれています(判定の誤り)") + # **認証は作業ディレクトリを作る前に確かめる。** 未認証のまま進むと、 + # 参加者が欠けた構成のまま最後まで走り切ってしまう。 + auth = check_auth(sorted(set(runtimes) | set(impl_capable))) + repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) head_branch = _sh( ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] @@ -597,6 +759,10 @@ def cmd_init(args: argparse.Namespace) -> None: "runtimes": runtimes, "impl_capable": impl_capable, "models": model_spec, + "auth": auth, + # 提案プロンプトへ許容値をそのまま列挙するために持たせる。 + # 定義は検証側(この CLI)にあり、状態ファイル経由で起動側へ渡す。 + "vocabulary": vocabulary(), "skills": {"required": list(REQUIRED_SKILLS)}, "max_outer_rounds": args.max_outer_rounds, "max_fix_rounds": args.max_fix_rounds, @@ -816,7 +982,10 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: - result = _result_path(state, runtime, stem_for(runtime, "propose", state["id"])) + result = _result_path( + state, runtime, + stem_for(runtime, "propose", state["id"], entry["round"]), + ) if not result.exists(): info(f"⚠ {runtime} の提案結果がありません: {result}") continue @@ -1043,6 +1212,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "item_id": f"R{entry['round']}-range", "commits": list(ordered_range), } + if not args.dry_run: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) _revert_item_commits(state, whole_round, args.dry_run) if not args.dry_run: # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 @@ -1060,12 +1234,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: - _push_with_retry_marker(path, state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) sys.exit(2) applied: list[str] = [] failed: list[str] = [] - reverted = 0 + scope = state.get("target_scope") or [] + # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した + # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から + # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると + # 中断からの復帰手段が無くなる。 + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress for item_id in entry["items"]: item = _find_item(state, item_id) got = reported.get(item_id) @@ -1077,25 +1260,31 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: work, _reported_shas(got), in_range, test_command, head_branch, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - problem = verify_apply_item(item, facts) + problem = verify_apply_item(item, facts, scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem item["test_failed"] = bool(got and "テストが成功していません" in problem) item["budget_exceeded"] = bool(got and "差分予算" in problem) - # **検証に失敗した項目のコミットを Pull Request に残さない。** - # 実装担当は項目ごとに push しているため、状態を `abandoned` にする - # だけでは差分が残り、以後のレビュー対象にも混入する。 + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, args.dry_run) failed.append(item_id) info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not args.dry_run: + statefile.save(path, state) entry["apply"] = { "applied": applied, @@ -1114,12 +1303,39 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の # つもりで実行した利用者の進行が壊れる。 if args.dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") + elif failed: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、検証を通っていない変更が Pull Request に残り、次の実行は + # 処理済みガードで素通りしてしまう。 + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, failed) + if result["mode"] == "round": + # 積み直せなかった。合意済みの項目も含めて全件捨てる。 + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it.setdefault( + "failure_reason", + "残す項目を積み直せなかったため、ラウンドごと取り消した", + ) + applied, failed = [], list(entry["items"]) + entry["apply"]["applied"] = applied + entry["apply"]["failed"] = failed + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + # 保存してから push する。push が失敗しても、記録とローカルの git が + # 食い違わない。 + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) else: - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) - if reverted: - _push_with_retry_marker(path, state, entry) if not applied: info("全項目が失敗したため、このラウンドのレビューは行いません") @@ -1291,27 +1507,42 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) return + if args.dry_run: + _drop_items(state, entry, targets, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + return + + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、Pull Request 側には未検証の差分が残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, targets) + if result["mode"] == "round": + info("積み直せなかったため、このラウンドで適用した項目を全件見送ります") + targets = list(entry["apply"].get("applied") or targets) + + already = {d.get("item_id") for d in state["deferred_items"]} for item_id in targets: item = _find_item(state, item_id) - count = _revert_item_commits(state, item, args.dry_run) item["status"] = "abandoned" item.setdefault("failure_reason", "修正ラウンドの上限に達しても指摘が解決しなかった") + if item_id in already: + continue state["deferred_items"].append({ "item_id": item_id, "path": item["path"], "symbol": item["symbol"], "smell": item["smell"], "round": entry["round"], "defer_reason": item["failure_reason"], }) - info(f"↩ {item_id} を取り消しました({count} コミット)") + info(f"↩ {item_id} を見送りました") entry["abandoned"] = targets state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - return # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) - _push_with_retry_marker(path, state, entry) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) def cmd_merge_fix(args: argparse.Namespace) -> None: @@ -1416,7 +1647,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: needs_push = False for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) + problem = verify_fix_commit(commit, state.get("target_scope") or []) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1433,6 +1664,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) _revert_item_commits( state, {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", @@ -1706,6 +1941,12 @@ def commit_diff_lines(work: str, sha: str) -> int: return total +def commit_files(work: str, sha: str) -> list[str]: + """コミットが触ったファイルのリポジトリ相対パス。範囲の検査に使う。""" + out = _git_out(work, ["show", "--name-only", "--format=", sha]) + return [p.strip() for p in (out or "").splitlines() if p.strip()] + + def commit_touches_tests(work: str, sha: str) -> bool: """コミットがテストの置き場所を触っているか。""" out = _git_out(work, ["show", "--name-only", "--format=", sha]) @@ -1843,6 +2084,7 @@ def collect_commit_facts( "exists": True, "trailers": commit_trailers(work, full), "diff_lines": commit_diff_lines(work, full), + "files": commit_files(work, full), "touches_tests": commit_touches_tests(work, full), "test_status": run_test_at( work, full, test_command, head_branch, test_timeout @@ -1958,6 +2200,171 @@ def _revert_item_commits( return len(shas) +def _reset_hard(work: str, sha: Optional[str]) -> None: + """着手前の HEAD へ戻す。半端な履歴を Pull Request に残さないための後始末。""" + if sha: + subprocess.run(["git", "reset", "--hard", sha], cwd=work, + capture_output=True, text=True) + + +def _revert_range(work: str, ordered: list[str], before: Optional[str]) -> None: + """範囲を**新しい順に**全て取り消す。失敗したら着手前へ戻して中断する。 + + 範囲全体を新しい順にたどる取り消しは、履歴をそのまま逆再生するだけなので + **競合しない**。競合するのは「一部のコミットだけを飛ばして戻す」ときである。 + """ + for sha in ordered: + r = subprocess.run( + ["git", "revert", "--no-edit", sha], + cwd=work, capture_output=True, text=True, + ) + if r.returncode != 0: + subprocess.run(["git", "revert", "--abort"], cwd=work, + capture_output=True, text=True) + _reset_hard(work, before) + die( + f"コミット {sha} を取り消せませんでした: {r.stderr.strip()[:400]}" + f"(HEAD を {before} へ戻しました)" + ) + + +def _replay_commits(work: str, shas: list[str]) -> Optional[dict[str, str]]: + """残す項目のコミットを**古い順に**積み直し、`{元の SHA: 新しい SHA}` を返す。 + + 競合したら `None` を返す。**ここで中断しない。** どの項目を残せるか決められない + だけなので、呼び出し側がラウンド全件の取り消しへ退避できる。 + """ + mapping: dict[str, str] = {} + for sha in shas: + r = subprocess.run( + ["git", "cherry-pick", "--allow-empty", sha], + cwd=work, capture_output=True, text=True, + ) + if r.returncode != 0: + subprocess.run(["git", "cherry-pick", "--abort"], cwd=work, + capture_output=True, text=True) + info(f"⚠ {sha[:7]} を積み直せませんでした: {r.stderr.strip()[:200]}") + return None + mapping[sha] = _git_out(work, ["rev-parse", "HEAD"]) or sha + return mapping + + +def _commit_owner( + work: str, state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, str]: + """このラウンドの `コミット → 改善項目 ID` の対応。完全な SHA へ正規化する。 + + どの項目にも属さないコミット(過去の取り消しなど)はここに現れない。 + 積み直しの対象から外すために、**属さないこと**を判定できる形にしておく。 + """ + owner: dict[str, str] = {} + for item_id in entry["items"]: + item = _find_item(state, item_id, required=False) + if item is None: + continue + for sha in item.get("commits") or []: + if not isinstance(sha, str) or not sha.strip(): + continue + full = _git_out(work, ["rev-parse", "--verify", f"{sha.strip()}^{{commit}}"]) + owner[full or sha.strip()] = item_id + return owner + + +def _drop_items( + state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], + dry_run: bool = False, +) -> dict[str, Any]: + """改善項目を取り消し、残す項目を積み直す。 + + **範囲を新しい順に全て戻してから、残す項目を古い順に積み直す。** 項目のコミット + だけを戻すと、取り消し対象より新しい**別項目**のコミットが同じ箇所を触っている + ときに必ず競合する(実測では採用 5 件のうち 4 件が同一ファイルの隣接領域を + 変更しており、取り消しが競合して進行が止まった)。 + + 積み直しが競合したときは着手前 HEAD へ戻し、**ラウンド全件の取り消しへ退避する**。 + どの項目を残せるか決められない以上、半端な履歴を残すより全件捨てる方が安全である。 + + 戻り値の `mode` は次の 3 つ。 + + | 値 | 意味 | + | --- | --- | + | `item` | 項目単位で取り消し、残す項目を積み直した | + | `round` | 積み直せず、ラウンド全件を取り消した(退避) | + | `skip` | 取り消すものが無かった(取り消し済み) | + """ + work = state["worktrees"]["work"] + pending = [ + i for i in drop_ids + if not (_find_item(state, i, required=False) or {}).get("reverted") + ] + if not pending: + info("↩ 取り消し対象は取り消し済みです") + return {"mode": "skip", "dropped": [], "reverted": 0, "replayed": 0} + + head = _git_out(work, ["rev-parse", "HEAD"]) + ordered = commits_in_range(work, entry.get("apply_base_sha"), head or "HEAD") + if ordered is None: + # 起点を記録していない状態ファイル(旧版)では積み直せない。 + # 従来どおり項目のコミットだけを新しい順に戻す。 + info("⚠ 適用の範囲を確定できないため、項目のコミットだけを取り消します") + reverted = 0 + for item_id in pending: + reverted += _revert_item_commits(state, _find_item(state, item_id), dry_run) + return {"mode": "item", "dropped": pending, + "reverted": reverted, "replayed": 0} + + owner = _commit_owner(work, state, entry) + drop = set(pending) + keep_ids = [ + i for i in entry["items"] + if i not in drop + and not (_find_item(state, i, required=False) or {}).get("reverted") + ] + # `ordered` は新しい順なので、積み直しは反転して古い順にする。 + # **どの項目にも属さないコミット(過去の取り消しなど)は積み直さない。** + replay = [s for s in reversed(ordered) if owner.get(s) in keep_ids] + + if dry_run: + for sha in ordered: + info(f"(dry-run)git revert --no-edit {sha}") + for sha in replay: + info(f"(dry-run)git cherry-pick {sha}") + return {"mode": "item", "dropped": pending, + "reverted": len(ordered), "replayed": len(replay)} + + _revert_range(work, ordered, head) + mapping = _replay_commits(work, replay) + mode = "item" + if mapping is None: + info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") + _reset_hard(work, head) + _revert_range(work, ordered, head) + mapping, mode = {}, "round" + + dropped = list(entry["items"]) if mode == "round" else pending + for item_id in entry["items"]: + item = _find_item(state, item_id, required=False) + if item is None: + continue + if mode == "round" or item_id not in keep_ids: + item["reverted"] = True + continue + # **積み直しで SHA が変わる。** 記録を更新しないと、次の取り消しが + # 履歴に無い SHA を指してしまう。 + item["commits"] = [mapping[s] for s in replay if owner.get(s) == item_id] + + entry.setdefault("drops", []).append({ + "at": statefile.now(), "mode": mode, "dropped": dropped, + "reverted": len(ordered), "replayed": len(mapping), + }) + info( + f"↩ 取り消し {len(ordered)} コミット / 積み直し {len(mapping)} コミット" + f"({'ラウンド全件へ退避' if mode == 'round' else '項目単位'})" + ) + return {"mode": mode, "dropped": dropped, + "reverted": len(ordered), "replayed": len(mapping)} + + def _order_newest_first(work: str, shas: list[str]) -> list[str]: """コミットを **git の履歴順(新しい順)** に並べ替える。 diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py index b6828948..e5c46d59 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py @@ -767,3 +767,106 @@ def failing_sh(cmd, **kw): assert [c for c in pushes if c[:2] == ["git", "push"]], "再試行していない" assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +# ---------- 巻き戻して積み直す取り消し ---------- + +def _range_state(tmp_path, findings, item_ids=("R1-001", "R1-002")): + """適用の起点を記録した状態。**積み直しの経路**を通る。""" + import json as _json + state_path = _state(tmp_path, findings, item_ids=item_ids) + state = read_state(state_path) + state["rounds"][0]["apply_base_sha"] = "BASE" + state_path.write_text(_json.dumps(state, ensure_ascii=False), encoding="utf-8") + return state_path + + +def _range_env(refactor, monkeypatch, ordered, pick_rc=0): + """範囲と git 操作を差し替える。`ordered` は新しい順。""" + calls: list[list[str]] = [] + picked: list[str] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + rc = 0 + if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: + rc = pick_rc + if rc == 0: + picked.append(cmd[-1]) + return subprocess.CompletedProcess(cmd, rc, "", "conflict" if rc else "") + + def fake_git_out(work, args): + if args[:2] == ["rev-parse", "--verify"]: + return args[-1].replace("^{commit}", "") + if args == ["rev-parse", "HEAD"]: + return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + return "HEAD_BEFORE" + + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + monkeypatch.setattr(refactor, "_git_out", fake_git_out) + monkeypatch.setattr(refactor, "commits_in_range", + lambda work, base, head: list(ordered)) + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: "") + return calls + + +def test_abandon_replays_the_items_that_stay( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """見送る項目より新しいコミットがあっても競合しないこと。 + + 範囲を新しい順に全て戻してから、残す項目を古い順に積み直す。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + # 履歴は R1-002 のコミットが新しい + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + + refactor.cmd_abandon_items(_args()) + + assert [c[-1] for c in calls if c[:2] == ["git", "revert"]] == [ + "sha-R1-002", "sha-R1-001"] + assert [c[-1] for c in calls if c[:2] == ["git", "cherry-pick"]] == ["sha-R1-002"] + + state = read_state(state_path) + by_id = {i["item_id"]: i for i in state["items"]} + assert by_id["R1-001"]["status"] == "abandoned" + assert by_id["R1-002"]["status"] == "reviewing" + assert by_id["R1-002"]["commits"] == ["new-sha-R1-002"] + + +def test_abandon_falls_back_to_the_whole_round_on_a_replay_conflict( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"], pick_rc=1) + + refactor.cmd_abandon_items(_args()) + + assert ["git", "cherry-pick", "--abort"] in calls + state = read_state(state_path) + assert all(i["status"] == "abandoned" for i in state["items"]) + assert sorted(d["item_id"] for d in state["deferred_items"]) == ["R1-001", "R1-002"] + assert state["rounds"][0]["drops"][-1]["mode"] == "round" + + +def test_abandon_marks_pending_push_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + marks: list[bool] = [] + _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + marks.append(read_state(state_path)["rounds"][0].get("pending_push")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + refactor.cmd_abandon_items(_args()) + + assert marks and marks[0] is True + assert read_state(state_path)["rounds"][0]["pending_push"] is False diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_drop_items_git.py new file mode 100644 index 00000000..bf828a03 --- /dev/null +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_drop_items_git.py @@ -0,0 +1,221 @@ +"""取り消しと積み直しを**実際の git** で確かめる。 + +差し替えたコマンド列だけを見ても「競合しないか」は示せない。ここでは本物の +リポジトリを作り、2 つの改善項目の位置関係を変えて挙動を確かめる。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル / 離れた行 | 項目単位で取り消し、残す項目は積み直せる | +| 同一ファイルの隣接行 | 積み直せないのでラウンド全件へ退避する | + +**隣接する変更は git だけでは分離できない。** 取り消した側の行が消えると、残す側の +パッチが前提にしている文脈も消えるためである。退避してでも Pull Request を +決定的な状態に保つことを優先する。 +""" +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git が必要") + +LINES = [f"line{i}\n" for i in range(1, 41)] + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, + text=True, check=True) + + +def _commit(repo, message): + _git("add", "-A", cwd=repo) + _git("-c", "user.email=t@e.st", "-c", "user.name=test", + "commit", "-qm", message, cwd=repo) + return _git("rev-parse", "HEAD", cwd=repo).stdout.strip() + + +def _make_repo(tmp_path, second_change): + """`R1-001` が 3 行目を、`R1-002` が `second_change` で示す箇所を変える。""" + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + _git("init", "-q", str(repo), cwd=tmp_path) + (repo / "src" / "foo.py").write_text("".join(LINES), encoding="utf-8") + (repo / "src" / "bar.py").write_text("".join(LINES), encoding="utf-8") + base = _commit(repo, "init") + + lines = list(LINES) + lines[2] = "line3-by-R1-001\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + c1 = _commit(repo, "R1-001") + + second_change(repo, lines) + c2 = _commit(repo, "R1-002") + return {"repo": repo, "base": base, "c1": c1, "c2": c2} + + +def _touch_adjacent_line(repo, lines): + lines = list(lines) + lines[3] = "line4-by-R1-002\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + + +def _touch_distant_line(repo, lines): + lines = list(lines) + lines[30] = "line31-by-R1-002\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + + +def _touch_other_file(repo, lines): + other = list(LINES) + other[2] = "line3-by-R1-002\n" + (repo / "src" / "bar.py").write_text("".join(other), encoding="utf-8") + + +@pytest.fixture +def adjacent_repo(tmp_path): + """同一ファイルの**隣接行**を触る 2 項目。実機で進行が止まった位置関係。""" + return _make_repo(tmp_path, _touch_adjacent_line) + + +@pytest.fixture +def distant_repo(tmp_path): + """同一ファイルの**離れた行**を触る 2 項目。""" + return _make_repo(tmp_path, _touch_distant_line) + + +@pytest.fixture +def separate_repo(tmp_path): + """**別ファイル**を触る 2 項目。""" + return _make_repo(tmp_path, _touch_other_file) + + +def _state(built): + entry = { + "round": 1, + "items": ["R1-001", "R1-002"], + "apply_base_sha": built["base"], + "apply": {"applied": ["R1-001", "R1-002"], "failed": []}, + } + state = { + "worktrees": {"work": str(built["repo"])}, + "rounds": [entry], + "items": [ + {"item_id": "R1-001", "round": 1, "status": "reviewing", + "commits": [built["c1"]]}, + {"item_id": "R1-002", "round": 1, "status": "reviewing", + "commits": [built["c2"]]}, + ], + } + return state, entry + + +def _content(built, name="foo.py"): + return (built["repo"] / "src" / name).read_text(encoding="utf-8") + + +# ---------- 前提の確認 ---------- + +def test_reverting_only_the_older_commit_conflicts(adjacent_repo): + """古い方だけを戻すと本当に競合すること。 + + これが競合しないなら、取り消しの作り直しそのものが不要になる。 + """ + r = subprocess.run( + ["git", "revert", "--no-edit", adjacent_repo["c1"]], + cwd=adjacent_repo["repo"], capture_output=True, text=True, + ) + assert r.returncode != 0, "競合しない位置関係になっている(テストの前提が崩れた)" + subprocess.run(["git", "revert", "--abort"], cwd=adjacent_repo["repo"], + capture_output=True, text=True) + + +# ---------- 項目単位で取り消せる場合 ---------- + +@pytest.mark.parametrize("fixture_name", ["distant_repo", "separate_repo"]) +def test_drop_older_item_keeps_the_newer_one(refactor, request, fixture_name): + """独立した変更なら、古い項目だけを取り消して新しい項目を残せること。""" + built = request.getfixturevalue(fixture_name) + state, entry = _state(built) + result = refactor._drop_items(state, entry, ["R1-001"]) + + assert result["mode"] == "item" + assert "line3-by-R1-001" not in _content(built), "取り消した項目の変更が残っている" + assert "R1-002" in _content(built) + _content(built, "bar.py"), \ + "残すはずの項目の変更が消えている" + + by_id = {i["item_id"]: i for i in state["items"]} + assert by_id["R1-001"]["reverted"] is True + # 積み直しで SHA が変わるので、記録も追従していること + head = _git("rev-parse", "HEAD", cwd=built["repo"]).stdout.strip() + assert by_id["R1-002"]["commits"] == [head] + + +def test_drop_newer_item_keeps_the_older_one(refactor, distant_repo): + """新しい項目だけを取り消す向きでも成立すること。""" + state, entry = _state(distant_repo) + assert refactor._drop_items(state, entry, ["R1-002"])["mode"] == "item" + assert "line3-by-R1-001" in _content(distant_repo) + assert "line31-by-R1-002" not in _content(distant_repo) + + +def test_second_drop_after_the_first_still_works(refactor, distant_repo): + """1 回目で積み直した SHA に対して、もう一度取り消せること。 + + 積み直しで SHA が変わるので、記録を更新していないとここで破綻する。 + """ + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001"]) + assert refactor._drop_items(state, entry, ["R1-002"])["mode"] == "item" + assert _content(distant_repo) == "".join(LINES) + + +def test_dropping_is_idempotent(refactor, distant_repo): + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001"]) + head = _git("rev-parse", "HEAD", cwd=distant_repo["repo"]).stdout.strip() + + assert refactor._drop_items(state, entry, ["R1-001"])["mode"] == "skip" + assert _git("rev-parse", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() == head + + +# ---------- 積み直せない場合 ---------- + +def test_adjacent_changes_fall_back_to_the_whole_round(refactor, adjacent_repo): + """隣接する変更は分離できない。退避して全件取り消すこと。 + + 半端な履歴を残すより、決定的な状態へ落とす方が安全である。 + """ + state, entry = _state(adjacent_repo) + result = refactor._drop_items(state, entry, ["R1-001"]) + + assert result["mode"] == "round" + assert _content(adjacent_repo) == "".join(LINES), "着手前の内容へ戻っていない" + assert all(i["reverted"] for i in state["items"]) + assert entry["drops"][-1]["mode"] == "round" + + +def test_dropping_every_item_returns_to_the_base_tree(refactor, distant_repo): + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001", "R1-002"]) + assert _content(distant_repo) == "".join(LINES) + + +def test_history_is_never_rewritten(refactor, distant_repo): + """`--force` を使わずに済むよう、前進だけで戻すこと。""" + state, entry = _state(distant_repo) + before = _git("rev-list", "--count", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() + refactor._drop_items(state, entry, ["R1-001"]) + after = _git("rev-list", "--count", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() + assert int(after) > int(before), "履歴を書き換えている" + assert built_commits_still_reachable(distant_repo) + + +def built_commits_still_reachable(built) -> bool: + """着手前のコミットが履歴から消えていないこと。""" + log = _git("rev-list", "HEAD", cwd=built["repo"]).stdout.split() + return built["c1"] in log and built["base"] in log diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_init.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_init.py index 9e566545..4987b7d6 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_init.py @@ -86,6 +86,9 @@ def fake_sh(cmd, cwd=None, check=True): monkeypatch.setattr(refactor, "_sh", fake_sh) monkeypatch.chdir(origin_repo) monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) + # 認証確認は実際の CLI を起動する。ここでは対象外なので飛ばす + # (確認そのものは `test_init_checks_cli_authentication` で見る)。 + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") refactor.cmd_init(args) return _run @@ -234,3 +237,94 @@ def test_diverged_worktree_stops_the_run(run_init, tmp_path): with pytest.raises(SystemExit): run_init(_args(tmp_path)) + + +# ---------- 語彙と認証 ---------- + +def test_init_records_the_vocabulary_for_the_prompt(run_init, tmp_path, refactor): + """許容値をプロンプトへ列挙できるよう、語彙集合を状態へ残すこと。 + + 手順書の見出しは日本語なので、「語彙に限定する」とだけ書くと読んだ側が + 日本語を語彙と解釈する(実測で gemini の提案 4 件が全件見送りになった)。 + """ + run_init(_args(tmp_path)) + _, state = _state_of(tmp_path) + assert state["vocabulary"]["smells"]["long_method"] == "長すぎるメソッド" + assert "extract_method" in state["vocabulary"]["techniques"] + assert state["vocabulary"]["severities"] == ["minor", "major", "critical"] + # 定義は検証側の 1 箇所だけに置く + assert state["vocabulary"]["smells"] == refactor.SMELLS + + +def _probe_result(refactor, monkeypatch, outcomes): + """認証確認コマンドの結果を差し替える。`{ランタイム: (rc, 出力)}`。""" + def fake_run(cmd, **kwargs): + for runtime, probe in refactor.AUTH_PROBES.items(): + if list(cmd) == list(probe): + rc, out = outcomes.get(runtime, (0, "ok")) + return subprocess.CompletedProcess(cmd, rc, out, "") + raise AssertionError(f"想定外の呼び出し: {cmd}") + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + + +def test_check_auth_passes_when_every_cli_is_logged_in(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {}) + results = refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + assert all(r["ok"] for r in results.values()) + + +def test_check_auth_fails_on_a_non_zero_exit(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {"kiro": (1, "")}) + with pytest.raises(SystemExit) as e: + refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + assert e.value.code == refactor.ABORT + + +def test_check_auth_fails_when_the_output_says_not_logged_in(refactor, monkeypatch): + """終了コード 0 でも未認証を示すことがある(kiro は成否を終了コードで表さない)。""" + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {"kiro": (0, "Not logged in")}) + with pytest.raises(SystemExit): + refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + + +def test_check_auth_fails_when_the_cli_is_missing(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + + def missing(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(refactor.subprocess, "run", missing) + with pytest.raises(SystemExit): + refactor.check_auth(["codex"]) + + +def test_check_auth_can_be_skipped_explicitly(refactor, monkeypatch): + """確認コマンドは CLI の版で変わる。飛ばせる逃げ道を残す。""" + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") + + def never(cmd, **kwargs): + raise AssertionError("認証確認を実行してはいけない") + + monkeypatch.setattr(refactor.subprocess, "run", never) + assert refactor.check_auth(["codex", "gemini"]) == {} + + +def test_init_checks_cli_authentication(refactor, origin_repo, monkeypatch, tmp_path): + """未認証の CLI があれば初期化ごと中断すること。 + + 参加者が 1 人欠けた構成のまま進むと、その者の提案とレビューが無いまま収束する。 + """ + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + monkeypatch.chdir(origin_repo) + monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) + _probe_result(refactor, monkeypatch, {"gemini": (1, "Authentication failed")}) + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: pytest.fail("認証確認より前に gh を呼んでいる"), + ) + with pytest.raises(SystemExit) as e: + refactor.cmd_init(_args(tmp_path)) + assert e.value.code == refactor.ABORT diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py index ccb44961..3074e0fa 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py @@ -383,24 +383,56 @@ def test_self_reported_values_cannot_pass_the_check( assert "テストが成功していません" in state["items"][0]["failure_reason"] -def test_failed_item_commits_are_reverted( - refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts -): - """検証に失敗した項目のコミットを Pull Request に残さない。 +def _drop_env(refactor, monkeypatch, revert_rc=0, pick_rc=0): + """取り消しと積み直しを実際には走らせず、順序と引数を記録する。 - 実装担当は項目ごとに push しているため、状態を `abandoned` にするだけでは - 差分が残り、以後のレビュー対象にも混入する。 + `git rev-parse HEAD` は**直前に積み直したコミット**に応じた値を返す。 + 積み直しで SHA が変わることを、状態の更新まで含めて確かめられるようにする。 """ + calls: list[list[str]] = [] + picked: list[str] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + rc = 0 + if cmd[:2] == ["git", "revert"] and "--abort" not in cmd: + rc = revert_rc + if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: + rc = pick_rc + if rc == 0: + picked.append(cmd[-1]) + return subprocess.CompletedProcess(cmd, rc, "", "conflict" if rc else "") + + def fake_git_out(work, args): + if args[:2] == ["rev-parse", "--verify"]: + return args[-1].replace("^{commit}", "") + if args == ["rev-parse", "HEAD"]: + return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + return "HEAD_BEFORE" + + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + monkeypatch.setattr(refactor, "_git_out", fake_git_out) + pushes: list[list[str]] = [] + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: pushes.append(list(cmd)) or "") + return calls, pushes + + +def _two_item_apply(tmp_path, env_tmp_dir, git_facts): + """1 件成功・1 件失敗の適用結果を用意する。失敗するのは R1-002。""" items = [item(item_id="R1-001"), item(item_id="R1-002")] state_path = _state_with_items(tmp_path, items) env_tmp_dir(state_path) - git_facts({ - "ok111": fact(sha="ok111"), - "bad111": fact(sha="bad111", diff_lines=400, - trailers=trailers(item_id="R1-002")), - "bad222": fact(sha="bad222", diff_lines=400, - trailers=trailers(item_id="R1-002")), - }) + git_facts( + { + "ok111": fact(sha="ok111"), + "bad111": fact(sha="bad111", diff_lines=400, + trailers=trailers(item_id="R1-002")), + "bad222": fact(sha="bad222", diff_lines=400, + trailers=trailers(item_id="R1-002")), + }, + # 履歴は bad222 が最も新しい + in_range=["bad222", "bad111", "ok111"], + ) write_result(state_path, "codex-apply-r1", { "base_sha": "aaa", "items": [ @@ -409,36 +441,181 @@ def test_failed_item_commits_are_reverted( {"item_id": "R1-002", "commits": [{"sha": "bad111"}, {"sha": "bad222"}]}, ], }) + return state_path - calls: list[list[str]] = [] - monkeypatch.setattr( - refactor.subprocess, "run", - lambda cmd, **kw: calls.append(list(cmd)) - or subprocess.CompletedProcess(cmd, 0, "", ""), - ) - pushes: list[list[str]] = [] - monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: pushes.append(cmd) or "") - # git の履歴は bad222 が最も新しい - monkeypatch.setattr( - refactor, "_git_out", - lambda work, args: ("bad222\nbad111\nok111" if args[:1] == ["rev-list"] - else args[-1].replace("^{commit}", "")), - ) + +def test_dropping_an_item_replays_the_kept_items( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """範囲を新しい順に全て戻し、残す項目を古い順に積み直すこと。 + + 失敗した項目のコミット**だけ**を戻すと、あとから同じ箇所を触った別項目の + コミットと必ず競合する。範囲全体の巻き戻しは履歴の逆再生なので競合しない。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, pushes = _drop_env(refactor, monkeypatch) refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) - reverts = [c for c in calls if c[:2] == ["git", "revert"]] - # 新しいコミットから順に戻す - assert [c[-1] for c in reverts] == ["bad222", "bad111"] + reverts = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + picks = [c[-1] for c in calls if c[:2] == ["git", "cherry-pick"]] + assert reverts == ["bad222", "bad111", "ok111"], "範囲を新しい順に全て戻していない" + assert picks == ["ok111"], "残す項目だけを積み直していない" assert pushes, "取り消し後に push していない" for cmd in pushes: - assert "--force" not in cmd + assert "--force" not in cmd and "--no-verify" not in cmd state = read_state(state_path) by_id = {i["item_id"]: i for i in state["items"]} assert by_id["R1-001"]["status"] == "reviewing" assert by_id["R1-002"]["status"] == "abandoned" - assert by_id["R1-002"]["commits"] == ["bad111", "bad222"] + assert by_id["R1-002"]["reverted"] is True + # 積み直しで SHA が変わるので、記録も追従すること + assert by_id["R1-001"]["commits"] == ["new-ok111"] + + +def test_replay_conflict_falls_back_to_whole_round( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """積み直せないときはラウンド全件の取り消しへ退避すること。 + + どの項目を残せるか決められない以上、半端な履歴を残すより全件捨てる方が安全。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, _ = _drop_env(refactor, monkeypatch, pick_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == 2, "全件失敗として次の提案ラウンドへ進むこと" + + assert ["git", "cherry-pick", "--abort"] in calls + assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + state = read_state(state_path) + assert all(i["status"] == "abandoned" for i in state["items"]) + assert state["rounds"][0]["apply"]["applied"] == [] + assert state["rounds"][0]["drops"][-1]["mode"] == "round" + + +def test_revert_failure_aborts_with_the_abort_code( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しに失敗したら「全件失敗」ではなく**中断**として終わること。 + + 2(全件失敗)と同じ扱いにすると、検証を通っていない変更を Pull Request に + 残したまま次の提案ラウンドが始まる。 + """ + _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == refactor.ABORT == 4 + assert ["git", "revert", "--abort"] in calls + assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + + +def test_progress_is_recorded_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しの前に判定を残すこと。中断しても到達点が状態から読める。""" + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + seen: list[list[dict]] = [] + + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + seen.append(read_state(state_path)["rounds"][0].get("apply_progress")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + assert seen, "取り消しが走っていない" + recorded = {p["item_id"]: p["result"] for p in seen[0]} + assert recorded == {"R1-001": "ok", "R1-002": "failed"} + + +def test_pending_push_is_marked_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しへ着手する前に再送信の印を立てること。 + + 取り消しは済んだのに push できずに終わると、検証を通っていない変更が + Pull Request に残り、次の実行は処理済みガードで素通りする。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + marks: list[bool] = [] + + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + marks.append(read_state(state_path)["rounds"][0].get("pending_push")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert marks and marks[0] is True + + +def test_pending_push_is_cleared_after_a_successful_push( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +def test_out_of_scope_commit_fails_the_item( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """指定した範囲の外を触ったコミットを検証で捕まえること。 + + 範囲を必須にした目的(提案の発散と変更の肥大を防ぐ)を、検証へ反映する。 + """ + items = [item(item_id="R1-001")] + state_path = _state_with_items(tmp_path, items) + env_tmp_dir(state_path) + git_facts({"out111": fact( + sha="out111", files=["src/foo.py", "dist/foo.py"], + )}) + write_result(state_path, "codex-apply-r1", { + "base_sha": "aaa", + "items": [{"item_id": "R1-001", "commits": [{"sha": "out111"}]}], + }) + _drop_env(refactor, monkeypatch) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == 2 + state = read_state(state_path) + assert state["items"][0]["out_of_scope"] is True + assert "dist/foo.py" in state["items"][0]["failure_reason"] + + +def test_scope_check_matches_only_on_path_prefix(refactor): + assert refactor.path_in_scope("src/foo.py", ["src"]) + assert refactor.path_in_scope("src", ["src"]) + assert not refactor.path_in_scope("src2/foo.py", ["src"]), "前方一致の取りこぼし" + assert not refactor.path_in_scope("dist/foo.py", ["src"]) + # 範囲が空なら検査しない(指定が無いのに全件落とさない) + assert refactor.out_of_scope_files({"files": ["any.py"]}, []) == [] def test_no_push_when_nothing_was_reverted( diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_proposals.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_proposals.py index 6e5ea1a3..e9882455 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_proposals.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_proposals.py @@ -185,9 +185,9 @@ def test_merge_proposals_command_creates_items( "durations": {}, "reviews": [], }]) env_tmp_dir(state_path) - write_result(state_path, "codex-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "gemini-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "kiro-propose-rf130", {"items": []}) + write_result(state_path, "codex-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "gemini-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": []}) refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -210,7 +210,7 @@ def test_merge_proposals_command_exits_2_when_nothing_adopted( }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": []}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": []}) with pytest.raises(SystemExit) as e: refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -234,9 +234,9 @@ def test_non_object_proposal_result_is_treated_as_empty( "durations": {}, "reviews": [], }]) env_tmp_dir(state_path) - write_result(state_path, "codex-propose-rf130", ["配列で返ってきた"]) - write_result(state_path, "gemini-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "kiro-propose-rf130", {"items": [proposal()]}) + write_result(state_path, "codex-propose-rf130-r1", ["配列で返ってきた"]) + write_result(state_path, "gemini-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": [proposal()]}) refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -259,7 +259,7 @@ def test_merge_proposals_is_idempotent(refactor, tmp_path, env_tmp_dir, no_git): }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": [proposal()]}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": [proposal()]}) args = type("A", (), {"id": 130})() refactor.cmd_merge_proposals(args) @@ -285,7 +285,7 @@ def test_merge_proposals_replays_the_converged_exit_code( }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": []}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": []}) args = type("A", (), {"id": 130})() for _ in range(2): diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_prepare_worktrees.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_prepare_worktrees.py index 08da12aa..14973eaa 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_prepare_worktrees.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_prepare_worktrees.py @@ -250,3 +250,35 @@ def test_empty_destination_is_provisioned(repo): dest.mkdir() _run(repo) assert (dest / "SKILL.md").is_file() + + +# ---------- gemini の読み取り除外 ---------- + +def test_gemini_gets_a_setting_that_allows_reading_the_provisioned_skills(repo): + """gemini は除外設定を**読み取りにも**適用するため、無効にする設定を置く。 + + 置かないと、配置した手順書を `read_file` で一切開けず、 + 語彙を読めないまま提案が語彙外になって全件降格する。 + """ + _run(repo) + settings = repo["root"] / "gemini" / ".gemini" / "settings.json" + assert settings.is_file(), "gemini の設定が置かれていない" + conf = json.loads(settings.read_text(encoding="utf-8")) + # 項目名は gemini の版で変わる。新旧どちらの形式でも書く + assert conf["context"]["fileFiltering"]["respectGitIgnore"] is False + assert conf["context"]["fileFiltering"]["respectGeminiIgnore"] is False + assert conf["fileFiltering"]["respectGitIgnore"] is False + assert conf["fileFiltering"]["respectGeminiIgnore"] is False + + +def test_gemini_settings_are_not_in_the_diff(repo): + _run(repo) + status = _git("status", "--short", cwd=repo["root"] / "gemini") + assert status.stdout.strip() == "", f"差分に現れている: {status.stdout}" + + +def test_only_gemini_gets_the_reading_setting(repo): + """他のランタイムの設定は触らない。""" + _run(repo) + for rt in ("codex", "kiro"): + assert not (repo["root"] / rt / ".gemini").exists() diff --git a/plugins/ndf-kiro/README.md b/plugins/ndf-kiro/README.md index bcd26566..0aa8879e 100644 --- a/plugins/ndf-kiro/README.md +++ b/plugins/ndf-kiro/README.md @@ -12,7 +12,7 @@ cat plugins/ndf-kiro/VERSION # 導入済みプロジェクトの版数 python3 -c "import json;print(json.load(open('.kiro/agents/ndf.json'))['description'])" -# => NDF統合開発エージェント(Kiro CLI用 / v8.1.0) +# => NDF統合開発エージェント(Kiro CLI用 / v8.2.0) ``` `install.sh` は実行時にも `NDF バージョン: <版数>` を表示する。 diff --git a/plugins/ndf-kiro/VERSION b/plugins/ndf-kiro/VERSION index 8104cabd..fbb9ea12 100644 --- a/plugins/ndf-kiro/VERSION +++ b/plugins/ndf-kiro/VERSION @@ -1 +1 @@ -8.1.0 +8.2.0 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/SKILL.md b/plugins/ndf-kiro/skills/cross-refactoring/SKILL.md index c5651050..7713b6b2 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf-kiro/skills/cross-refactoring/SKILL.md @@ -37,7 +37,8 @@ allowed-tools: | 役割の分離 | 提案・レビューは**ホストを除く 3 者**、適用は**gemini を除く 3 者**。両者は重なるが一致しない | | レビューの単位 | **提案ラウンドの差分全体**に対して 1 回。項目ごとに回すと CLI 起動回数が採用件数に比例して膨らむ | | 収束しない項目 | **捨てる。** リファクタリングは任意の作業なので、揉める提案を Pull Request に残さない | -| 取り消しの単位 | **改善項目ごと。** 合意済みの項目は残す。そのために 1 手 1 コミットを機械検証する | +| 取り消しの単位 | **改善項目ごと(独立している範囲で)。** 範囲を新しい順に全て戻し、残す項目を積み直す。同一ファイルの隣接行を触る項目どうしは git だけでは分離できないため、そのときは**ラウンド全件へ退避する** | +| 範囲の扱い | `--scope` は**検証にも効く**。範囲外を触ったコミットを含む項目は失敗になる。生成物の同期は進行側が収束後にまとめて行う | | 検証の情報源 | **git と実際のテスト実行。** 結果ファイルの申告は検証に使わない(書き換えるだけで通る検査にしない) | | 投稿 | **AI 自身が `gh api` で投稿する。** ホストの作業文脈に差分やレビュー本文を載せない | | 状態の永続化 | `/.cross_refactoring/cross-refactoring-rf<番号>-state.json` に集約。中断・再開可能 | @@ -48,7 +49,7 @@ allowed-tools: | 引数 | 意味 | 既定 | | --- | --- | --- | | `[PR番号]` | 対象の Pull Request | 必須 | -| `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須** | 必須 | +| `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須。** 検証にも効くので、現状固定テストの置き場所も含める | 必須 | | `--host claude\|codex\|kiro` | ホストの明示指定。未指定時は環境変数から推定 | 推定 | | `--model RT=MODEL` | ランタイムごとのモデル。繰り返し指定できる | CLI の既定 | | `--baseline-test CMD` | 着手前と各コミットで実行するテスト。**振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須** | 必須 | @@ -59,7 +60,7 @@ allowed-tools: | `--test-timeout SEC` | テスト 1 回あたりの上限秒数。超えたら失敗として扱う | `900` | ```text -/ndf:cross-refactoring 130 --scope src/services --baseline-test "pytest -q" +/ndf:cross-refactoring 130 --scope src/services tests/services --baseline-test "pytest -q" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=opus-5 /ndf:cross-refactoring 130 --scope src --host codex --max-outer-rounds 1 ``` @@ -88,6 +89,20 @@ allowed-tools: ## 前提 - `gh` CLI が認証済みで、`jq` と `uv`(または Python 3.10 以上)が使える +- 参加する CLI が**すべてログイン済み**である。`init` が認証状態を確認し、1 つでも + 未認証なら中断する(未認証の CLI は起動から 15 秒で終わり、結果を残さないまま + 担当から脱落するため、確認しないと参加者が欠けた構成のまま進行する) + + | ランタイム | 確認コマンド | + | --- | --- | + | claude | `claude auth status` | + | codex | `codex login status` | + | gemini | `gemini --skip-trust -p ping --output-format text` | + | kiro | `kiro-cli whoami` | + + 確認コマンドは CLI の版で変わりうる。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` で + 飛ばせる(飛ばしたことは出力に残る) + - ホストごとに次の CLI が使える(不足していると初期化時に失敗する) | ホスト | 必要な CLI | @@ -139,7 +154,19 @@ PLUGIN_ROOT="${PLUGIN_ROOT:-plugins/ndf-kiro}" SCRIPTS="$PLUGIN_ROOT/skills/cross-refactoring/scripts" LIB="$PLUGIN_ROOT/skills/cross-review/scripts/lib" -eval "$("$SCRIPTS/refactor.py" init "$PR" --scope $SCOPE \ +# **中断(終了コード 4)は握り潰さない。** 取り消しに失敗した状態を「全件失敗」と +# 同じ扱いにすると、検証を通っていない変更を Pull Request に残したまま次の提案が +# 始まる。判定に使う終了コードだけを呼び出し側へ返し、それ以外は進行ごと止める。 +rf() { + "$SCRIPTS/refactor.py" "$@"; local rc=$? + if [ $rc -eq 4 ]; then + echo "❌ cross-refactoring を中断しました(refactor.py $1)" >&2 + exit 4 + fi + return $rc +} + +eval "$(rf init "$PR" --scope $SCOPE \ --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS)" @@ -147,18 +174,18 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" "$SCRIPTS/prepare-worktrees.sh" "$ID" while :; do # 提案ラウンドの繰り返し - eval "$("$SCRIPTS/refactor.py" start-round "$ID")" || break + eval "$(rf start-round "$ID")" || break # 終了コード 1 = 繰り返し終了 for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ - --stem-template '{agent}-propose-rf{id}' - "$SCRIPTS/refactor.py" merge-proposals "$ID" || break # 終了コード 2 = 採用 0 件 + --stem-template "{agent}-propose-rf{id}-r$ROUND" + rf merge-proposals "$ID" || break # 終了コード 2 = 採用 0 件 "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-apply-r$ROUND" --timeout 3600 - "$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" || continue # 全件失敗 + rf merge-apply "$ID" "$ROUND" || continue # 終了コード 2 = 全件失敗 # 適用後の状態をレビュー担当へ見せるため、読み取り用を同期する "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" @@ -169,23 +196,37 @@ while :; do # 提案ラウンドの繰り返 done "$LIB/monitor.py" "$ID" --agents "$REVIEWERS_CSV" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-review-r$ROUND" - "$SCRIPTS/refactor.py" judge-review "$ID" "$ROUND"; rc=$? + rf judge-review "$ID" "$ROUND"; rc=$? [ $rc -eq 0 ] && break # 2 者とも承認 [ $rc -eq 3 ] && continue # 形式不正 — 差し戻して再レビュー - if "$SCRIPTS/refactor.py" should-abandon "$ID" "$ROUND"; then - "$SCRIPTS/refactor.py" abandon-items "$ID" "$ROUND"; break + if rf should-abandon "$ID" "$ROUND"; then + rf abandon-items "$ID" "$ROUND"; break fi "$SCRIPTS/launch-cli.sh" "$IMPL" fix "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-fix-r$ROUND" - "$SCRIPTS/refactor.py" merge-fix "$ID" "$ROUND" + rf merge-fix "$ID" "$ROUND" # 修正後の状態を再レビューさせる。同期しないと古い差分を評価してしまう "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" done - "$SCRIPTS/refactor.py" advance "$ID" || break + rf advance "$ID" || break done + +# 収束後にまとめて生成物を同期する(**進行側の責務**)。編集元から配布物を生成する +# 規約を持つリポジトリでは、実装担当に同期させると範囲外の変更が生まれる。 +# 同期が要るなら、ここで生成してから Step 7 の最終ゲートへ渡す。 ``` +### 終了コード + +| コード | 意味 | 進行 | +| --- | --- | --- | +| 0 | 正常 | 続ける | +| 1 | 繰り返しの終了(`start-round` / `advance`) | 抜ける | +| 2 | 判定の結果(採用 0 件 / 全件失敗 / 変更要求 など) | 各コマンドの表に従う | +| 3 | レビュー結果の形式不正 | 差し戻して再レビュー | +| **4** | **中断**(取り消しの失敗、認証切れ、範囲を確定できないなど) | **進行ごと止める** | + 続けて **Step 7** で `/ndf:cross-review ` を実行する。レビューはラウンド単位なので、 **ラウンドを跨いだ整合はここで見る**。収束したら Draft を解除し、 `refactor.py report "$ID" --metrics` の出力を報告する。 @@ -199,6 +240,8 @@ done | ホストのサブエージェントで適用する | ホストの作業文脈に差分が載り、実装者とレビュー担当の独立性が崩れる | | `launch-cli.sh` に「ホストなら起動しない」分岐を入れる | ホストは適用担当として起動しうる。分岐はランタイム名だけで行う | | `--scope` を省く | 提案が発散し、Pull Request が肥大する | +| 実装担当に生成物を同期させる | 範囲外の変更が生まれ、差分予算を超える。同期は進行側が収束後にまとめて行う | +| 取り消しの失敗を「全件失敗」として次のラウンドへ進む | 検証を通っていない変更が Pull Request に残る。終了コード 4 は必ず進行ごと止める | | `--dry-run` の出力を実行結果と混同する | 確認用なので git も状態ファイルも触らない。進行は 1 歩も進まない | | 複数の改善項目を 1 コミットにまとめる | 取り消し範囲が項目単位で決まらなくなる。適用結果の検証で失敗になる | | 結果ファイルの申告を検証の材料にする | 実装担当は報告する側。JSON を書き換えるだけで通る検査は機械検証ではない | diff --git a/plugins/ndf-kiro/skills/cross-refactoring/docs/01-state-and-propose.md b/plugins/ndf-kiro/skills/cross-refactoring/docs/01-state-and-propose.md index bfbb6735..487a9b4c 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/docs/01-state-and-propose.md +++ b/plugins/ndf-kiro/skills/cross-refactoring/docs/01-state-and-propose.md @@ -37,7 +37,16 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 使うと古い HEAD に対して提案・適用してしまう。早送りできない(履歴が分かれた) ときは中断する。`git fetch` に失敗したときも中断する(古い `origin/` へ 早送りして「同期したつもり」になるのを防ぐ) -5. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 +5. **認証状態の確認** — 参加する CLI を 1 つずつ確認し、未認証なら**初期化ごと中断する** + (終了コード 4)。存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、 + 結果ファイルを残さないまま担当から脱落するが、それでも初期化は成功として扱われるため、 + **参加者が 1 人欠けた構成のまま最後まで進んでしまう**(実測)。作業ディレクトリを + 作る前に確認する。確認コマンドは CLI の版で変わりうるので `NDF_SKIP_AUTH_CHECK=1` + で飛ばせるが、飛ばしたことは必ず出力へ残す +6. **語彙の受け渡し** — 検証側が持つスメル・手法・重要度の集合を状態ファイルの + `vocabulary` へ書く。提案プロンプトはここから**許容値をそのまま列挙する**。 + 定義を 1 箇所に保ったまま、読ませ方の不確実性を減らすためである +7. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 壊れた状態から始めると、壊したのか元から壊れていたのか区別できない。 この引数は**必須**である。振る舞いが変わっていないことを示す手段が無い書き換えは、 `refactoring` Skill の定義からして構造改善ではない @@ -121,6 +130,30 @@ gemini は NDF の配布先ではないため「標準の配置先」を持た **対象リポジトリ本体を書き換える**ことになり、「配置は作業ディレクトリの中だけで完結 させる」という前提を破る。 +#### gemini は除外を読み取りにも適用する + +全件無視をそのまま効かせると、**gemini は配置した手順書を 1 つも開けない**。 + +```text +Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +is ignored by configured ignore patterns. +``` + +手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と +書いている前提が崩れるため、gemini の作業ディレクトリへ +`.gemini/settings.json` を置き、**読み取り側の除外だけを無効にする**。 + +```json +{ + "context": { "fileFiltering": { "respectGitIgnore": false, "respectGeminiIgnore": false } }, + "fileFiltering": { "respectGitIgnore": false, "respectGeminiIgnore": false } +} +``` + +設定の項目名は gemini の版で変わるため、**新旧どちらの形式でも書く**(0.55.1 で +読み取り成功を確認)。設定自身も差分に出さないよう `.gemini/` ごと無視する。 +配置は**手順書を配る前**に行う。gemini は起動時に 1 度だけ設定を読むためである。 + ### 読ませ方(明示パスを必ず書く) Skill を配置しても、**本文を読むかどうかはランタイムによって違う**。 @@ -150,7 +183,7 @@ for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ - --stem-template '{agent}-propose-rf{id}' + --stem-template "{agent}-propose-rf{id}-r$ROUND" ``` 3 CLI を並列で起動し、同一のプロンプトで提案させる。**提案フェーズにホストは現れない** @@ -159,6 +192,29 @@ done 提出形式は [prompts/propose.md](../prompts/propose.md) にある。 +### 結果ファイル名にラウンド番号を入れる + +CLI の起動時に同名の結果ファイルを消すため、**提案の結果ファイル名にもラウンド番号が +要る**。番号を入れないと、2 巡目の提案が始まった時点で 1 巡目の提案内容が失われる。 +統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは復元できない**。 + +| フェーズ | ファイル名 | +| --- | --- | +| 提案 | `<ランタイム>-propose-rf-r<ラウンド>-result.json` | +| 適用 | `<ランタイム>-apply-r<ラウンド>-result.json` | +| レビュー | `<ランタイム>-review-r<ラウンド>-result.json` | + +### 語彙は列挙して渡す + +提案プロンプトは `smell` / `technique` / `severity` の**許容値を列挙する**。 +「手順書の語彙に限定する」とだけ書くと、手順書の見出しが日本語であるため、読んだ側が +日本語を語彙と解釈する。実測では gemini の提案 4 件が全て日本語の語彙で返り、 +内容は妥当だったにもかかわらず語彙外の降格規則で全件が見送りになった。 + +列挙の元は `refactor.py` の `SMELLS` / `TECHNIQUES`(検証側が持つ集合)である。 +`init` が状態ファイルの `vocabulary` へ書き、`launch-cli.sh` が読んで差し込む。 +**同じ一覧を 2 か所に書かない。** + ## Step 3: 提案のマージ ```bash diff --git a/plugins/ndf-kiro/skills/cross-refactoring/docs/02-apply-and-review.md b/plugins/ndf-kiro/skills/cross-refactoring/docs/02-apply-and-review.md index 93146ae1..e9b460b8 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/docs/02-apply-and-review.md +++ b/plugins/ndf-kiro/skills/cross-refactoring/docs/02-apply-and-review.md @@ -8,9 +8,13 @@ "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-apply-r$ROUND" --timeout 3600 -"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 終了コード 2 = 全件失敗 +"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 2 = 全件失敗 / 4 = 中断 ``` +終了コード 2 と 4 を**必ず区別する**。同じ扱いにすると、取り消しに失敗した状態を +「全件失敗」として次の提案ラウンドへ進み、検証を通っていない変更が Pull Request に +残ったまま新しい提案が始まる(実測)。 + 実装担当を**1 ラウンド 1 回**起動し、採用した改善項目を優先度順に**直列適用**させる。 並列適用はしない(同一ブランチへの同時コミットは競合とレビュー単位の曖昧化を招く)。 @@ -36,8 +40,38 @@ | テストが無い経路は先に現状固定テスト | `git show --name-only` | `test_gap` が真の項目は、先頭コミットがテストの置き場所を触っている | | 項目の分離 | git のトレーラー | 各コミットの `Item-Id` がその項目と一致する。複数の項目を 1 コミットにまとめたら失敗 | | 差分予算 | `git show --numstat` | 実差分の合計が `estimated_diff_lines` の 2 倍を超えたら失敗(範囲の逸脱) | +| 対象範囲の遵守 | `git show --name-only` | 触ったファイルが全て `--scope` の中にある。1 つでも外なら失敗 | | 機能変更の混入なし | — | 機械判定は不可能。レビュー観点に委ねる | +#### 範囲の指定は検証にも効かせる + +`--scope` を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を検証へ +反映しないと目的を果たせない。実測では、編集元から配布物を生成する規約に従った結果として +範囲外の 3 系統が変更され、差分が 4 倍に膨らんで差分予算を超えた。実装担当の判断自体は +リポジトリの規約に沿っており、**規約と範囲の指定が衝突していた**のが原因である。 + +そこで責務を分ける。 + +| 誰が | 何を | +| --- | --- | +| 実装担当 | `--scope` の中だけを変更する。生成物・配布物の同期はしない | +| 進行側(ホスト) | 収束後にまとめて生成物を同期する | + +判定は**前方一致だけ**で行い、除外規則は持たない。規則を書けるようにすると、 +規則を 1 行足すだけで範囲の検査を骨抜きにできる。 + +そのため **`--scope` には現状固定テストの置き場所も含める**。含めないと、 +`test_gap` が真の項目で「テストを先に足せ」と「範囲外を触るな」が両立しなくなり、 +その項目は必ず失敗する。 + +```bash +# ❌ テストの置き場所が入っていない +--scope src/services + +# ✅ 直す対象とテストの置き場所を両方入れる +--scope src/services tests/services +``` + テストの実行はコミットごとに `git checkout --detach ` して行い、終わったら 必ず元のブランチへ戻す。1 ラウンドの採用上限があるため実行回数は数回に収まり、 CLI の起動コストに比べれば無視できる。 @@ -101,9 +135,28 @@ Pull Request に残る。**都合の悪い変更を申告しないだけで検 **1 件の失敗でラウンドを止めない。** 失敗した項目だけを見送りにして、残りは採用する。 全件失敗のときだけ終了コード 2 を返し、次の提案ラウンドへ進む。 -**失敗した項目のコミットはその場で取り消して push する。** 実装担当は項目ごとに -push しているため、状態を `abandoned` にするだけでは差分が Pull Request に残り、 -以後のレビュー対象にも混入する。何が消えるかを先に見たいときは `--dry-run` を付ける。 +#### 判定はその都度記録する + +**項目ごとの判定が出るたびに状態ファイルへ保存する。** まとめて最後に保存すると、 +取り消しの途中で中断したときに適用の記録が一切残らない。実測では 14 件の適用コミットと +3 件の取り消しコミットが実在するのに、状態ファイルは全項目 `pending` / コミット 0 件の +ままだった。**どのコミットが検証を通ったのかを状態から復元できず、同じ手順を叩き直しても +再開できない。** 再開可能性は収束ループの前提なので、ここが崩れると復帰手段が無くなる。 + +記録先は `rounds[].apply_progress`(項目 ID / 判定 / 理由 / コミット)である。 + +#### 取り消しは判定が出そろってからまとめて行う + +**失敗した項目のコミットを Pull Request に残さない。** 実装担当は項目ごとに push して +いるため、状態を `abandoned` にするだけでは差分が残り、以後のレビュー対象にも混入する。 +何が消えるかを先に見たいときは `--dry-run` を付ける。 + +ただし**項目ごとにその場で戻してはならない**。詳細は +[取り消しは巻き戻して積み直す](#取り消しは巻き戻して積み直す)を参照する。 + +**取り消しへ着手する前に `pending_push` を立てる。** 取り消しは済んだのに push できずに +終わると、Pull Request 側には未検証の差分が残るのに、次の実行は処理済みガードで +素通りしてしまう。印があれば、次の実行が判定より先に再送信する。 ### コミットトレーラーの形式 @@ -255,10 +308,10 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 修正ラウンドが上限に達したら、**未解決の指摘が紐づく改善項目だけ**を取り消す。 指摘の無い項目と解決済みの項目は Pull Request に残す。 -- 取り消しは**git の履歴から新しい順に並べ直して** `git revert` する。申告された - 順序は信用しない。古いコミットから戻すと後続の取り消しが競合して止まる +- 取り消しは**範囲を新しい順に全て戻してから、残す項目を積み直す**(次節) - 取り消しに失敗したら、**着手前の HEAD まで戻してから**中断する。先行して成功した - 取り消しだけが履歴に残ると、再実行で不整合になって進めなくなる + 取り消しだけが履歴に残ると、再実行で不整合になって進めなくなる。中断は + **終了コード 4** で表し、「全件失敗」(2)と区別する - **保存してから push する。** 逆順にすると、push の失敗時に取り消しはローカルへ 残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう - push の前に `pending_push` を立て、成功したら消す。失敗したまま終わっても、 @@ -275,6 +328,63 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 修正は必須だが、リファクタリングは任意の作業なので、揉める提案を Pull Request に 残さない方が安全である。 +### 取り消しは巻き戻して積み直す + +**項目のコミットだけを新しい順に戻す方法では足りない。** その並べ替えは同じ項目に属する +コミットの中でしか働かず、取り消し対象より新しい**別項目**のコミットが同じ箇所を触って +いると必ず競合する。実測では、採用した 5 件のうち 4 件が同一ファイルの隣接領域を変更して +おり、取り消しが競合して進行が止まった。 + +```text +❌ R1-002 のコミット ea3209c を取り消せませんでした: error: could not revert ea3209c... +``` + +そこで次の順で行う。 + +```mermaid +flowchart LR + A["範囲 base..HEAD を
新しい順に全て revert"] --> B["残す項目のコミットを
古い順に cherry-pick"] + B -->|成功| C["項目単位の取り消し完了"]:::ok + B -->|競合| D["着手前 HEAD へ reset
範囲を全て revert"] --> E["ラウンド全件を取り消し"]:::stop + + classDef ok fill:#dfd,stroke:#383 + classDef stop fill:#fdd,stroke:#933 +``` + +- 範囲全体を新しい順にたどる取り消しは**履歴の逆再生**なので競合しない。競合するのは + 「一部のコミットだけを飛ばして戻す」ときである +- 積み直しの対象は**残す項目に属するコミットだけ**。過去の取り消しコミットのように + どの項目にも属さないものは積み直さない +- **積み直しで SHA が変わる。** 状態ファイルの `items[].commits` を新しい SHA へ + 更新する。更新しないと、次の取り消しが履歴に無い SHA を指す +- `git push --force` は使わない。履歴の書き換えではなく、**revert と cherry-pick による + 前進だけ**で行う + +#### 隣接する変更は分離できない + +**項目単位で取り消せるのは、項目どうしの変更が独立しているときだけである。** +取り消す側と残す側が同一ファイルの隣接行を触っていると、積み直しの +`git cherry-pick` も競合する。取り消した側の行が消えることで、残す側のパッチが +前提にしている文脈も消えるためで、これは git だけでは決められない。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル | 項目単位 | +| 同一ファイルの離れた行 | 項目単位 | +| 同一ファイルの隣接行 | **ラウンド全件へ退避** | + +退避したときは着手前の状態まで戻し、ラウンドの全項目を見送る。半端な履歴を残すより、 +決定的な状態へ落とす方が安全である。退避したことは `rounds[].drops[].mode` に +`round` として残るので、頻度は報告から読める。 + +実測(Pull Request #118)では採用 5 件のうち 4 件が同一ファイルの隣接領域を変更して +いた。**この構成では退避が普通に起こる**と見込んでおく。項目単位を保ちたいなら、 +`--max-items-per-round` を下げるか、`--scope` を狭めて 1 ラウンドで同じファイルの +近い場所を複数触らせないようにする。 + +範囲の起点を記録していない状態ファイル(旧版)では積み直せないため、従来どおり +項目のコミットだけを新しい順に戻す。この経路では取り消し自体が競合しうる。 + ## Step 7: 提案ラウンドの収束と最終ゲート ```bash @@ -292,7 +402,11 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 重複率は `path` + `symbol` + `smell` の集合比較で求める。同じ提案が毎ラウンド出続けて 終わらない状態を検知するためである。 -終了後は **`/ndf:cross-review `** で Pull Request 全体を承認収束にかける。 +終了後、生成物の同期が要るリポジトリでは**ここで進行側がまとめて同期する**。 +実装担当に同期させると範囲外の変更が生まれ、差分予算にも影響する(Step 4 の +「範囲の指定は検証にも効かせる」を参照)。 + +続けて **`/ndf:cross-review `** で Pull Request 全体を承認収束にかける。 レビューはラウンド単位なので、**ラウンドを跨いだ整合はここで見る**。 ## Step 8: 報告 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/prompts/apply.md b/plugins/ndf-kiro/skills/cross-refactoring/prompts/apply.md index 0db2b60f..ad7fac79 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/prompts/apply.md +++ b/plugins/ndf-kiro/skills/cross-refactoring/prompts/apply.md @@ -56,6 +56,11 @@ Impl-Model: $RF_MODEL - **`git push --force` と `--no-verify` を使わない** - 作業ディレクトリの外を触らない +- **対象範囲(`$RF_SCOPE`)の外にあるファイルを 1 つも変更しない。** 範囲外を触った + コミットを含む項目は検証で失敗し、取り消されます +- **生成物・配布物の同期をしない。** このリポジトリに「編集元から配布物を生成する」 + 規約があっても、同期は**進行側が収束後にまとめて行う**責務です。ここで同期すると + 範囲外の変更が生まれ、差分予算も超えます - **機能変更を混ぜない。** 振る舞いを変える修正が必要だと分かったら、その項目は 適用せず `status` を `skipped` にして理由を書く - 提案された手順の範囲を超えない。ついでの整理をしない diff --git a/plugins/ndf-kiro/skills/cross-refactoring/prompts/fix.md b/plugins/ndf-kiro/skills/cross-refactoring/prompts/fix.md index df2814e7..b28b967d 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/prompts/fix.md +++ b/plugins/ndf-kiro/skills/cross-refactoring/prompts/fix.md @@ -57,6 +57,9 @@ Impl-Model: $RF_MODEL - **`git push --force` と `--no-verify` を使わない** - 作業ディレクトリの外を触らない +- **対象範囲(`$RF_SCOPE`)の外にあるファイルを 1 つも変更しない。** 範囲外を触った + 修正コミットがあると、その修正ラウンドの範囲ごと取り消されます +- **生成物・配布物の同期をしない。** 同期は進行側が収束後にまとめて行います - 指摘に無い箇所を「ついでに」直さない。ラウンドの差分が膨らみ、 どの変更がどの指摘に対応するのか追えなくなる diff --git a/plugins/ndf-kiro/skills/cross-refactoring/prompts/propose.md b/plugins/ndf-kiro/skills/cross-refactoring/prompts/propose.md index 2127e31b..cc299fee 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/prompts/propose.md +++ b/plugins/ndf-kiro/skills/cross-refactoring/prompts/propose.md @@ -20,6 +20,24 @@ $RF_SKILL_BLOCK $RF_EXCLUDED +## 語彙(この値だけを使う) + +`smell` と `technique` には**下の識別子をそのまま**書いてください。日本語の説明は +意味の対応を示すためのもので、**出力に日本語を書くと語彙外**として扱われ、 +自動的に最低の重要度へ降格し、しきい値で落ちます。 + +### `smell` に書ける値 + +$RF_VOCAB_SMELLS + +### `technique` に書ける値 + +$RF_VOCAB_TECHNIQUES + +### `severity` に書ける値 + +$RF_VOCAB_SEVERITIES + ## やること 1. 対象範囲のコードを読み、**調べる価値がある兆候**を探す @@ -54,9 +72,8 @@ $RF_EXCLUDED ``` - `path` はリポジトリ相対、`symbol` は関数・メソッド・クラスの名前 -- `smell` と `technique` は**手順書の語彙に限定**する。語彙外の値は自動で最低の - 重要度へ降格され、しきい値で落ちます -- `severity` は `critical` / `major` / `minor` のいずれか +- `smell` / `technique` / `severity` は**上に列挙した識別子のいずれか**。 + 列挙にない値(日本語を含む)は自動で最低の重要度へ降格され、しきい値で落ちます - `path` + `symbol` + `smell` が同じ提案は 1 件へ統合されます。**他のランタイムと 合意した提案ほど優先される**ので、独自性を狙わず素直に挙げてください diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/launch-cli.sh b/plugins/ndf-kiro/skills/cross-refactoring/scripts/launch-cli.sh index 1f221e20..c2d7468c 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/launch-cli.sh +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/launch-cli.sh @@ -45,7 +45,10 @@ MAX_ITEMS=$(jq -r '.max_items_per_round' "$STATE") case "$PHASE" in propose) - STEM=$TMP_DIR/$RUNTIME-propose-rf$ID + # **提案にもラウンド番号を入れる。** 起動時に同名の結果ファイルを消すため、 + # 番号が無いと 2 巡目の提案が 1 巡目の内容を消してしまう。 + [ "$ROUND" -ge 1 ] 2>/dev/null || { echo "propose には ROUND が必要です" >&2; exit 1; } + STEM=$TMP_DIR/$RUNTIME-propose-rf$ID-r$ROUND WORKDIR=$ROOT/$RUNTIME ;; apply|fix) @@ -112,7 +115,21 @@ export RF_MODEL=${MODEL:-default} RF_WORKDIR=$WORKDIR RF_STEM=$STEM export RF_SCOPE=$SCOPE RF_HEAD_BRANCH=$HEAD_BRANCH RF_BASE_BRANCH=$BASE_BRANCH export RF_BASELINE_TEST=$BASELINE_TEST RF_MAX_ITEMS=$MAX_ITEMS export RF_SKILL_BLOCK=$SKILL_BLOCK RF_EXCLUDED=$EXCLUDED + +# 語彙の許容値。**手順書を読ませるだけでは足りない。** 手順書の見出しは日本語なので、 +# 「語彙に限定する」とだけ書くと読んだ側が日本語を語彙と解釈し、語彙外の降格規則で +# 全件が見送りになる(実測)。検証側が持つ集合を状態ファイル経由で受け取り、 +# **許容値をそのまま列挙する**。 +VOCAB_SMELLS=$(jq -r '(.vocabulary.smells // {}) | to_entries[] | "- `\(.key)` — \(.value)"' "$STATE") +VOCAB_TECHNIQUES=$(jq -r '(.vocabulary.techniques // {}) | to_entries[] | "- `\(.key)` — \(.value)"' "$STATE") +VOCAB_SEVERITIES=$(jq -r '(.vocabulary.severities // []) | map("`" + . + "`") | join(" / ")' "$STATE") +[ -n "$VOCAB_SMELLS" ] || VOCAB_SMELLS="(状態ファイルに語彙がありません。手順書の語彙に従うこと)" +[ -n "$VOCAB_TECHNIQUES" ] || VOCAB_TECHNIQUES="(同上)" +[ -n "$VOCAB_SEVERITIES" ] || VOCAB_SEVERITIES="\`critical\` / \`major\` / \`minor\`" + export RF_ITEMS=$ITEMS_JSON RF_TMP_DIR=$TMP_DIR +export RF_VOCAB_SMELLS=$VOCAB_SMELLS RF_VOCAB_TECHNIQUES=$VOCAB_TECHNIQUES +export RF_VOCAB_SEVERITIES=$VOCAB_SEVERITIES # 雛形は `${RF_*}` を展開するだけの素の Markdown。コマンド置換は展開しない # (プロンプト本文に `$(...)` や backtick が現れても実行させないため)。 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/prepare-worktrees.sh b/plugins/ndf-kiro/skills/cross-refactoring/scripts/prepare-worktrees.sh index ea0b05ce..c13afe38 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/prepare-worktrees.sh +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/prepare-worktrees.sh @@ -131,9 +131,41 @@ provision_skill() { # 代わりに、配置したディレクトリ自身へ全件無視の `.gitignore` を置く。自分自身も # 無視されるため差分に現れず、他の未追跡ファイルには影響しない。 ignore_dir() { + mkdir -p "$1" printf '*\n' > "$1/.gitignore" } +# gemini は**除外設定を読み取りにも適用する**。上の全件無視をそのまま効かせると、 +# 配置した手順書を `read_file` で一切開けない。 +# +# Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +# is ignored by configured ignore patterns. +# +# 手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と +# 書いている前提が崩れるため、**作業ディレクトリ限定で**読み取り側の除外を無効にする。 +# 設定の項目名は gemini の版で変わるので、新旧どちらの形式でも書く(0.55.1 で確認)。 +configure_gemini_reading() { + local base=$1 + local dir="$base/.gemini" + mkdir -p "$dir" + cat > "$dir/settings.json" <<'GEMINI_SETTINGS_EOF' +{ + "context": { + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } + }, + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } +} +GEMINI_SETTINGS_EOF + # 設定そのものも差分に出さない。`.gemini/` ごと無視する。 + printf '*\n' > "$dir/.gitignore" +} + HEAD_SHA=$(git rev-parse "origin/$HEAD_BRANCH" 2>/dev/null || git -C "$WORK" rev-parse HEAD) @@ -160,6 +192,9 @@ MISSING=() CONFLICT=() for rt in "${RUNTIMES[@]}"; do + # 配置より先に置く。gemini は起動時に 1 度だけ設定を読むため、 + # 手順書を配ってから設定を書いても間に合う保証がない。 + [ "$rt" = "gemini" ] && configure_gemini_reading "$ROOT/$rt" entry='{}' for name in "${REQUIRED_SKILLS[@]}"; do status=$(provision_skill "$ROOT/$rt" "$rt" "$name") diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 082dc773..74a99d3e 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -48,9 +48,19 @@ import models as models_lib # noqa: E402 import statefile # noqa: E402 -die = statefile.die info = statefile.info +# 中断の終了コード。**「全件失敗」(2)と区別する。** 進行スクリプトは 2 なら次の +# 提案ラウンドへ進み、4 なら進行そのものを止める。区別しないと、取り消しに失敗した +# 状態を「全件失敗」として握り潰し、**検証を通っていない変更を Pull Request に +# 残したまま**次の提案が始まる(実測)。 +ABORT = 4 + + +def die(msg: str, code: int = ABORT) -> None: + """中断して終了する。既定は「中断」を表す終了コード。""" + statefile.die(msg, code) + # ---------------- 語彙 ---------------- # スメルと手法の語彙は `refactoring` Skill の references と 1 対 1 で対応させる。 @@ -101,6 +111,25 @@ SEVERITY_ORDER = {"unknown": 0, "minor": 1, "major": 2, "critical": 3} DEFAULT_SEVERITY_THRESHOLD = "minor" +# 提案が名乗ってよい重要度。`unknown` は降格先なので含めない。 +SEVERITIES: tuple[str, ...] = tuple(s for s in SEVERITY_ORDER if s != "unknown") + + +def vocabulary() -> dict[str, Any]: + """提案プロンプトへ**そのまま列挙する**ための語彙集合。 + + 手順書の見出しは日本語なので、「語彙に限定する」とだけ書くと読んだ側が + 日本語を語彙と解釈する(実測では gemini の提案 4 件が全て日本語で返り、 + 語彙外の降格規則により全件見送りになった)。**検証側が持つ集合をそのまま + 渡す**ことで、許容値の定義を 1 箇所に保ったまま列挙できる。 + """ + return { + "smells": dict(SMELLS), + "techniques": dict(TECHNIQUES), + "severities": list(SEVERITIES), + } + + # 適用と修正のコミットに必須のトレーラー。1 つでも欠けたら当該項目を失敗にする。 # 自由文で「codex が実装」と書かせると集計に使えないため、必ずトレーラー形式にする。 REQUIRED_TRAILERS = ("Item-Id", "Round", "Impl-Runtime", "Impl-Model") @@ -122,6 +151,25 @@ # 差し戻しを無限に繰り返すと、形式を満たせないランタイムでループが止まらなくなる。 MAX_INVALID_REVIEWS = 1 +# 認証状態の確認コマンド。**CLI の存在確認だけでは足りない。** 未認証の CLI は +# 起動から 15 秒で終わり、結果ファイルを残さないまま担当から脱落する(実測)。 +# それでも初期化は成功として扱われるため、参加者が 1 人欠けた構成のまま進行する。 +AUTH_PROBES: dict[str, tuple[str, ...]] = { + "claude": ("claude", "auth", "status"), + "codex": ("codex", "login", "status"), + # gemini には認証確認の副コマンドが無い。最小のプロンプトで疎通を見る。 + # 作業ディレクトリの信頼判定に引っ掛からないよう `--skip-trust` を付ける。 + "gemini": ("gemini", "--skip-trust", "-p", "ping", "--output-format", "text"), + "kiro": ("kiro-cli", "whoami"), +} +AUTH_PROBE_TIMEOUT = 120 + +# **終了コード 0 でも未認証を示すことがある。** kiro は成否を終了コードで表さない。 +UNAUTHENTICATED_MARKERS = ( + "not logged in", "not authenticated", "authentication failed", + "login required", "unauthorized", "please log in", +) + # ---------------- パス解決 ---------------- @@ -203,9 +251,15 @@ def _result_path(state: dict[str, Any], runtime: str, stem: str) -> pathlib.Path def stem_for(runtime: str, phase: str, state_id: int, round_no: Optional[int] = None) -> str: - """一時ファイル名の骨格。監視スクリプトの `--stem-template` と揃える。""" + """一時ファイル名の骨格。監視スクリプトの `--stem-template` と揃える。 + + **提案にもラウンド番号を入れる。** CLI の起動時に同名の結果ファイルを消すため、 + 番号が無いと 2 巡目の提案が始まった時点で 1 巡目の提案内容が失われる。 + 統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは + 復元できなくなる**(実測)。 + """ if phase == "propose": - return f"{runtime}-propose-rf{state_id}" + return f"{runtime}-propose-rf{state_id}-r{round_no}" return f"{runtime}-{phase}-r{round_no}" @@ -364,6 +418,51 @@ def duplicate_rate( # **git と実際のテスト実行**から取る。結果ファイルから使うのは「どのコミットが # どの項目のものか」という対応付けの手がかりだけである。 +def path_in_scope(path: str, scope: Iterable[str]) -> bool: + """`path` が対象範囲の中にあるか。判定は**前方一致だけ**で行う。 + + 除外規則を足さない。規則を書けるようにすると、規則を 1 行足すだけで + 範囲の検査を骨抜きにできてしまう。 + """ + for entry in scope: + prefix = str(entry).strip().rstrip("/") + if not prefix: + continue + if path == prefix or path.startswith(prefix + "/"): + return True + return False + + +def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: + """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" + paths = list(scope) + if not paths: + return [] + return sorted( + p for p in (commit.get("files") or []) if not path_in_scope(p, paths) + ) + + +def verify_scope(commit: dict[str, Any], scope: Iterable[str]) -> Optional[str]: + """対象範囲の外を触っていれば理由を返す。 + + 範囲を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を + 検証に反映しないと目的を果たせない。実測では、生成物を同期する規約に従った + 結果として範囲外が 3 系統変更され、差分が 4 倍に膨らんで差分予算を超えた。 + 生成物の同期が要る構成では、**同期は進行側の責務**として分離する。 + """ + outside = out_of_scope_files(commit, scope) + if not outside: + return None + shown = ", ".join(outside[:5]) + more = f" ほか {len(outside) - 5} 件" if len(outside) > 5 else "" + return ( + f"コミット {commit.get('sha', '?')} が対象範囲の外を変更しています" + f"({shown}{more})。生成物の同期は進行側が収束後にまとめて行います。" + "現状固定テストの置き場所が範囲外なら、`--scope` に含めてから実行してください" + ) + + def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: """コミットのトレーラーが 4 つ揃っているか。欠けていれば理由を返す。 @@ -377,7 +476,9 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という @@ -386,6 +487,9 @@ def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: if not commit.get("exists", True): return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -397,7 +501,8 @@ def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: def verify_apply_item( - item: dict[str, Any], facts: list[dict[str, Any]] + item: dict[str, Any], facts: list[dict[str, Any]], + scope: Optional[Iterable[str]] = None, ) -> Optional[str]: """1 項目の適用結果を検証する。問題があれば失敗理由を返す。 @@ -415,6 +520,9 @@ def verify_apply_item( "(申告だけで実体がありません)" ) problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -531,6 +639,56 @@ def unresolved_item_ids( # ---------------- サブコマンド ---------------- +def check_auth(runtimes: Iterable[str]) -> dict[str, dict[str, Any]]: + """参加する CLI の認証状態を確かめる。1 つでも欠けたら初期化を中断する。 + + 存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、結果ファイルを + 残さないまま提案・レビューの担当から脱落するが、**初期化は成功として扱われる** + ため、参加者が 1 人欠けた構成のまま最後まで進んでしまう。 + + 確認コマンドは CLI の版で変わりうるので、`NDF_SKIP_AUTH_CHECK` で飛ばせるように + しておく。飛ばしたことは必ず出力へ残す(黙って劣化させない)。 + """ + if os.environ.get("NDF_SKIP_AUTH_CHECK"): + info("⚠ NDF_SKIP_AUTH_CHECK が設定されているため認証確認を飛ばしました") + return {} + + results: dict[str, dict[str, Any]] = {} + failed: list[str] = [] + for runtime in runtimes: + probe = AUTH_PROBES.get(runtime) + if probe is None: + continue + env = dict(os.environ) + if runtime == "gemini": + # 新規パスは untrusted と判定されるため、確認でも信頼を明示する。 + env["GEMINI_CLI_TRUST_WORKSPACE"] = "true" + try: + r = subprocess.run(list(probe), capture_output=True, text=True, + timeout=AUTH_PROBE_TIMEOUT, env=env) + merged = f"{r.stdout}\n{r.stderr}".lower() + ok = r.returncode == 0 and not any( + m in merged for m in UNAUTHENTICATED_MARKERS + ) + detail = (r.stderr.strip() or r.stdout.strip())[:200] + except FileNotFoundError: + ok, detail = False, "コマンドが見つかりません" + except subprocess.TimeoutExpired: + ok, detail = False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" + results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} + info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") + if not ok: + failed.append(f"{runtime}({detail})") + + if failed: + die( + "認証されていない CLI があります: " + " / ".join(failed) + "。" + "参加者が欠けたまま進むと、その者の提案とレビューが無いまま収束します。" + "各 CLI でログインしてから再実行してください" + ) + return results + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -553,6 +711,10 @@ def cmd_init(args: argparse.Namespace) -> None: if host in runtimes: die(f"提案・レビューの母集合にホスト {host} が含まれています(判定の誤り)") + # **認証は作業ディレクトリを作る前に確かめる。** 未認証のまま進むと、 + # 参加者が欠けた構成のまま最後まで走り切ってしまう。 + auth = check_auth(sorted(set(runtimes) | set(impl_capable))) + repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) head_branch = _sh( ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] @@ -597,6 +759,10 @@ def cmd_init(args: argparse.Namespace) -> None: "runtimes": runtimes, "impl_capable": impl_capable, "models": model_spec, + "auth": auth, + # 提案プロンプトへ許容値をそのまま列挙するために持たせる。 + # 定義は検証側(この CLI)にあり、状態ファイル経由で起動側へ渡す。 + "vocabulary": vocabulary(), "skills": {"required": list(REQUIRED_SKILLS)}, "max_outer_rounds": args.max_outer_rounds, "max_fix_rounds": args.max_fix_rounds, @@ -816,7 +982,10 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: - result = _result_path(state, runtime, stem_for(runtime, "propose", state["id"])) + result = _result_path( + state, runtime, + stem_for(runtime, "propose", state["id"], entry["round"]), + ) if not result.exists(): info(f"⚠ {runtime} の提案結果がありません: {result}") continue @@ -1043,6 +1212,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "item_id": f"R{entry['round']}-range", "commits": list(ordered_range), } + if not args.dry_run: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) _revert_item_commits(state, whole_round, args.dry_run) if not args.dry_run: # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 @@ -1060,12 +1234,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: - _push_with_retry_marker(path, state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) sys.exit(2) applied: list[str] = [] failed: list[str] = [] - reverted = 0 + scope = state.get("target_scope") or [] + # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した + # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から + # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると + # 中断からの復帰手段が無くなる。 + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress for item_id in entry["items"]: item = _find_item(state, item_id) got = reported.get(item_id) @@ -1077,25 +1260,31 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: work, _reported_shas(got), in_range, test_command, head_branch, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - problem = verify_apply_item(item, facts) + problem = verify_apply_item(item, facts, scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem item["test_failed"] = bool(got and "テストが成功していません" in problem) item["budget_exceeded"] = bool(got and "差分予算" in problem) - # **検証に失敗した項目のコミットを Pull Request に残さない。** - # 実装担当は項目ごとに push しているため、状態を `abandoned` にする - # だけでは差分が残り、以後のレビュー対象にも混入する。 + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, args.dry_run) failed.append(item_id) info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not args.dry_run: + statefile.save(path, state) entry["apply"] = { "applied": applied, @@ -1114,12 +1303,39 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の # つもりで実行した利用者の進行が壊れる。 if args.dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") + elif failed: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、検証を通っていない変更が Pull Request に残り、次の実行は + # 処理済みガードで素通りしてしまう。 + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, failed) + if result["mode"] == "round": + # 積み直せなかった。合意済みの項目も含めて全件捨てる。 + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it.setdefault( + "failure_reason", + "残す項目を積み直せなかったため、ラウンドごと取り消した", + ) + applied, failed = [], list(entry["items"]) + entry["apply"]["applied"] = applied + entry["apply"]["failed"] = failed + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + # 保存してから push する。push が失敗しても、記録とローカルの git が + # 食い違わない。 + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) else: - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) - if reverted: - _push_with_retry_marker(path, state, entry) if not applied: info("全項目が失敗したため、このラウンドのレビューは行いません") @@ -1291,27 +1507,42 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) return + if args.dry_run: + _drop_items(state, entry, targets, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + return + + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、Pull Request 側には未検証の差分が残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, targets) + if result["mode"] == "round": + info("積み直せなかったため、このラウンドで適用した項目を全件見送ります") + targets = list(entry["apply"].get("applied") or targets) + + already = {d.get("item_id") for d in state["deferred_items"]} for item_id in targets: item = _find_item(state, item_id) - count = _revert_item_commits(state, item, args.dry_run) item["status"] = "abandoned" item.setdefault("failure_reason", "修正ラウンドの上限に達しても指摘が解決しなかった") + if item_id in already: + continue state["deferred_items"].append({ "item_id": item_id, "path": item["path"], "symbol": item["symbol"], "smell": item["smell"], "round": entry["round"], "defer_reason": item["failure_reason"], }) - info(f"↩ {item_id} を取り消しました({count} コミット)") + info(f"↩ {item_id} を見送りました") entry["abandoned"] = targets state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - return # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) - _push_with_retry_marker(path, state, entry) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) def cmd_merge_fix(args: argparse.Namespace) -> None: @@ -1416,7 +1647,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: needs_push = False for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) + problem = verify_fix_commit(commit, state.get("target_scope") or []) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1433,6 +1664,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) _revert_item_commits( state, {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", @@ -1706,6 +1941,12 @@ def commit_diff_lines(work: str, sha: str) -> int: return total +def commit_files(work: str, sha: str) -> list[str]: + """コミットが触ったファイルのリポジトリ相対パス。範囲の検査に使う。""" + out = _git_out(work, ["show", "--name-only", "--format=", sha]) + return [p.strip() for p in (out or "").splitlines() if p.strip()] + + def commit_touches_tests(work: str, sha: str) -> bool: """コミットがテストの置き場所を触っているか。""" out = _git_out(work, ["show", "--name-only", "--format=", sha]) @@ -1843,6 +2084,7 @@ def collect_commit_facts( "exists": True, "trailers": commit_trailers(work, full), "diff_lines": commit_diff_lines(work, full), + "files": commit_files(work, full), "touches_tests": commit_touches_tests(work, full), "test_status": run_test_at( work, full, test_command, head_branch, test_timeout @@ -1958,6 +2200,171 @@ def _revert_item_commits( return len(shas) +def _reset_hard(work: str, sha: Optional[str]) -> None: + """着手前の HEAD へ戻す。半端な履歴を Pull Request に残さないための後始末。""" + if sha: + subprocess.run(["git", "reset", "--hard", sha], cwd=work, + capture_output=True, text=True) + + +def _revert_range(work: str, ordered: list[str], before: Optional[str]) -> None: + """範囲を**新しい順に**全て取り消す。失敗したら着手前へ戻して中断する。 + + 範囲全体を新しい順にたどる取り消しは、履歴をそのまま逆再生するだけなので + **競合しない**。競合するのは「一部のコミットだけを飛ばして戻す」ときである。 + """ + for sha in ordered: + r = subprocess.run( + ["git", "revert", "--no-edit", sha], + cwd=work, capture_output=True, text=True, + ) + if r.returncode != 0: + subprocess.run(["git", "revert", "--abort"], cwd=work, + capture_output=True, text=True) + _reset_hard(work, before) + die( + f"コミット {sha} を取り消せませんでした: {r.stderr.strip()[:400]}" + f"(HEAD を {before} へ戻しました)" + ) + + +def _replay_commits(work: str, shas: list[str]) -> Optional[dict[str, str]]: + """残す項目のコミットを**古い順に**積み直し、`{元の SHA: 新しい SHA}` を返す。 + + 競合したら `None` を返す。**ここで中断しない。** どの項目を残せるか決められない + だけなので、呼び出し側がラウンド全件の取り消しへ退避できる。 + """ + mapping: dict[str, str] = {} + for sha in shas: + r = subprocess.run( + ["git", "cherry-pick", "--allow-empty", sha], + cwd=work, capture_output=True, text=True, + ) + if r.returncode != 0: + subprocess.run(["git", "cherry-pick", "--abort"], cwd=work, + capture_output=True, text=True) + info(f"⚠ {sha[:7]} を積み直せませんでした: {r.stderr.strip()[:200]}") + return None + mapping[sha] = _git_out(work, ["rev-parse", "HEAD"]) or sha + return mapping + + +def _commit_owner( + work: str, state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, str]: + """このラウンドの `コミット → 改善項目 ID` の対応。完全な SHA へ正規化する。 + + どの項目にも属さないコミット(過去の取り消しなど)はここに現れない。 + 積み直しの対象から外すために、**属さないこと**を判定できる形にしておく。 + """ + owner: dict[str, str] = {} + for item_id in entry["items"]: + item = _find_item(state, item_id, required=False) + if item is None: + continue + for sha in item.get("commits") or []: + if not isinstance(sha, str) or not sha.strip(): + continue + full = _git_out(work, ["rev-parse", "--verify", f"{sha.strip()}^{{commit}}"]) + owner[full or sha.strip()] = item_id + return owner + + +def _drop_items( + state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], + dry_run: bool = False, +) -> dict[str, Any]: + """改善項目を取り消し、残す項目を積み直す。 + + **範囲を新しい順に全て戻してから、残す項目を古い順に積み直す。** 項目のコミット + だけを戻すと、取り消し対象より新しい**別項目**のコミットが同じ箇所を触っている + ときに必ず競合する(実測では採用 5 件のうち 4 件が同一ファイルの隣接領域を + 変更しており、取り消しが競合して進行が止まった)。 + + 積み直しが競合したときは着手前 HEAD へ戻し、**ラウンド全件の取り消しへ退避する**。 + どの項目を残せるか決められない以上、半端な履歴を残すより全件捨てる方が安全である。 + + 戻り値の `mode` は次の 3 つ。 + + | 値 | 意味 | + | --- | --- | + | `item` | 項目単位で取り消し、残す項目を積み直した | + | `round` | 積み直せず、ラウンド全件を取り消した(退避) | + | `skip` | 取り消すものが無かった(取り消し済み) | + """ + work = state["worktrees"]["work"] + pending = [ + i for i in drop_ids + if not (_find_item(state, i, required=False) or {}).get("reverted") + ] + if not pending: + info("↩ 取り消し対象は取り消し済みです") + return {"mode": "skip", "dropped": [], "reverted": 0, "replayed": 0} + + head = _git_out(work, ["rev-parse", "HEAD"]) + ordered = commits_in_range(work, entry.get("apply_base_sha"), head or "HEAD") + if ordered is None: + # 起点を記録していない状態ファイル(旧版)では積み直せない。 + # 従来どおり項目のコミットだけを新しい順に戻す。 + info("⚠ 適用の範囲を確定できないため、項目のコミットだけを取り消します") + reverted = 0 + for item_id in pending: + reverted += _revert_item_commits(state, _find_item(state, item_id), dry_run) + return {"mode": "item", "dropped": pending, + "reverted": reverted, "replayed": 0} + + owner = _commit_owner(work, state, entry) + drop = set(pending) + keep_ids = [ + i for i in entry["items"] + if i not in drop + and not (_find_item(state, i, required=False) or {}).get("reverted") + ] + # `ordered` は新しい順なので、積み直しは反転して古い順にする。 + # **どの項目にも属さないコミット(過去の取り消しなど)は積み直さない。** + replay = [s for s in reversed(ordered) if owner.get(s) in keep_ids] + + if dry_run: + for sha in ordered: + info(f"(dry-run)git revert --no-edit {sha}") + for sha in replay: + info(f"(dry-run)git cherry-pick {sha}") + return {"mode": "item", "dropped": pending, + "reverted": len(ordered), "replayed": len(replay)} + + _revert_range(work, ordered, head) + mapping = _replay_commits(work, replay) + mode = "item" + if mapping is None: + info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") + _reset_hard(work, head) + _revert_range(work, ordered, head) + mapping, mode = {}, "round" + + dropped = list(entry["items"]) if mode == "round" else pending + for item_id in entry["items"]: + item = _find_item(state, item_id, required=False) + if item is None: + continue + if mode == "round" or item_id not in keep_ids: + item["reverted"] = True + continue + # **積み直しで SHA が変わる。** 記録を更新しないと、次の取り消しが + # 履歴に無い SHA を指してしまう。 + item["commits"] = [mapping[s] for s in replay if owner.get(s) == item_id] + + entry.setdefault("drops", []).append({ + "at": statefile.now(), "mode": mode, "dropped": dropped, + "reverted": len(ordered), "replayed": len(mapping), + }) + info( + f"↩ 取り消し {len(ordered)} コミット / 積み直し {len(mapping)} コミット" + f"({'ラウンド全件へ退避' if mode == 'round' else '項目単位'})" + ) + return {"mode": mode, "dropped": dropped, + "reverted": len(ordered), "replayed": len(mapping)} + + def _order_newest_first(work: str, shas: list[str]) -> list[str]: """コミットを **git の履歴順(新しい順)** に並べ替える。 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py index b6828948..e5c46d59 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py @@ -767,3 +767,106 @@ def failing_sh(cmd, **kw): assert [c for c in pushes if c[:2] == ["git", "push"]], "再試行していない" assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +# ---------- 巻き戻して積み直す取り消し ---------- + +def _range_state(tmp_path, findings, item_ids=("R1-001", "R1-002")): + """適用の起点を記録した状態。**積み直しの経路**を通る。""" + import json as _json + state_path = _state(tmp_path, findings, item_ids=item_ids) + state = read_state(state_path) + state["rounds"][0]["apply_base_sha"] = "BASE" + state_path.write_text(_json.dumps(state, ensure_ascii=False), encoding="utf-8") + return state_path + + +def _range_env(refactor, monkeypatch, ordered, pick_rc=0): + """範囲と git 操作を差し替える。`ordered` は新しい順。""" + calls: list[list[str]] = [] + picked: list[str] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + rc = 0 + if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: + rc = pick_rc + if rc == 0: + picked.append(cmd[-1]) + return subprocess.CompletedProcess(cmd, rc, "", "conflict" if rc else "") + + def fake_git_out(work, args): + if args[:2] == ["rev-parse", "--verify"]: + return args[-1].replace("^{commit}", "") + if args == ["rev-parse", "HEAD"]: + return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + return "HEAD_BEFORE" + + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + monkeypatch.setattr(refactor, "_git_out", fake_git_out) + monkeypatch.setattr(refactor, "commits_in_range", + lambda work, base, head: list(ordered)) + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: "") + return calls + + +def test_abandon_replays_the_items_that_stay( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """見送る項目より新しいコミットがあっても競合しないこと。 + + 範囲を新しい順に全て戻してから、残す項目を古い順に積み直す。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + # 履歴は R1-002 のコミットが新しい + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + + refactor.cmd_abandon_items(_args()) + + assert [c[-1] for c in calls if c[:2] == ["git", "revert"]] == [ + "sha-R1-002", "sha-R1-001"] + assert [c[-1] for c in calls if c[:2] == ["git", "cherry-pick"]] == ["sha-R1-002"] + + state = read_state(state_path) + by_id = {i["item_id"]: i for i in state["items"]} + assert by_id["R1-001"]["status"] == "abandoned" + assert by_id["R1-002"]["status"] == "reviewing" + assert by_id["R1-002"]["commits"] == ["new-sha-R1-002"] + + +def test_abandon_falls_back_to_the_whole_round_on_a_replay_conflict( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"], pick_rc=1) + + refactor.cmd_abandon_items(_args()) + + assert ["git", "cherry-pick", "--abort"] in calls + state = read_state(state_path) + assert all(i["status"] == "abandoned" for i in state["items"]) + assert sorted(d["item_id"] for d in state["deferred_items"]) == ["R1-001", "R1-002"] + assert state["rounds"][0]["drops"][-1]["mode"] == "round" + + +def test_abandon_marks_pending_push_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + marks: list[bool] = [] + _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + marks.append(read_state(state_path)["rounds"][0].get("pending_push")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + refactor.cmd_abandon_items(_args()) + + assert marks and marks[0] is True + assert read_state(state_path)["rounds"][0]["pending_push"] is False diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_drop_items_git.py new file mode 100644 index 00000000..bf828a03 --- /dev/null +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_drop_items_git.py @@ -0,0 +1,221 @@ +"""取り消しと積み直しを**実際の git** で確かめる。 + +差し替えたコマンド列だけを見ても「競合しないか」は示せない。ここでは本物の +リポジトリを作り、2 つの改善項目の位置関係を変えて挙動を確かめる。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル / 離れた行 | 項目単位で取り消し、残す項目は積み直せる | +| 同一ファイルの隣接行 | 積み直せないのでラウンド全件へ退避する | + +**隣接する変更は git だけでは分離できない。** 取り消した側の行が消えると、残す側の +パッチが前提にしている文脈も消えるためである。退避してでも Pull Request を +決定的な状態に保つことを優先する。 +""" +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git が必要") + +LINES = [f"line{i}\n" for i in range(1, 41)] + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, + text=True, check=True) + + +def _commit(repo, message): + _git("add", "-A", cwd=repo) + _git("-c", "user.email=t@e.st", "-c", "user.name=test", + "commit", "-qm", message, cwd=repo) + return _git("rev-parse", "HEAD", cwd=repo).stdout.strip() + + +def _make_repo(tmp_path, second_change): + """`R1-001` が 3 行目を、`R1-002` が `second_change` で示す箇所を変える。""" + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + _git("init", "-q", str(repo), cwd=tmp_path) + (repo / "src" / "foo.py").write_text("".join(LINES), encoding="utf-8") + (repo / "src" / "bar.py").write_text("".join(LINES), encoding="utf-8") + base = _commit(repo, "init") + + lines = list(LINES) + lines[2] = "line3-by-R1-001\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + c1 = _commit(repo, "R1-001") + + second_change(repo, lines) + c2 = _commit(repo, "R1-002") + return {"repo": repo, "base": base, "c1": c1, "c2": c2} + + +def _touch_adjacent_line(repo, lines): + lines = list(lines) + lines[3] = "line4-by-R1-002\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + + +def _touch_distant_line(repo, lines): + lines = list(lines) + lines[30] = "line31-by-R1-002\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + + +def _touch_other_file(repo, lines): + other = list(LINES) + other[2] = "line3-by-R1-002\n" + (repo / "src" / "bar.py").write_text("".join(other), encoding="utf-8") + + +@pytest.fixture +def adjacent_repo(tmp_path): + """同一ファイルの**隣接行**を触る 2 項目。実機で進行が止まった位置関係。""" + return _make_repo(tmp_path, _touch_adjacent_line) + + +@pytest.fixture +def distant_repo(tmp_path): + """同一ファイルの**離れた行**を触る 2 項目。""" + return _make_repo(tmp_path, _touch_distant_line) + + +@pytest.fixture +def separate_repo(tmp_path): + """**別ファイル**を触る 2 項目。""" + return _make_repo(tmp_path, _touch_other_file) + + +def _state(built): + entry = { + "round": 1, + "items": ["R1-001", "R1-002"], + "apply_base_sha": built["base"], + "apply": {"applied": ["R1-001", "R1-002"], "failed": []}, + } + state = { + "worktrees": {"work": str(built["repo"])}, + "rounds": [entry], + "items": [ + {"item_id": "R1-001", "round": 1, "status": "reviewing", + "commits": [built["c1"]]}, + {"item_id": "R1-002", "round": 1, "status": "reviewing", + "commits": [built["c2"]]}, + ], + } + return state, entry + + +def _content(built, name="foo.py"): + return (built["repo"] / "src" / name).read_text(encoding="utf-8") + + +# ---------- 前提の確認 ---------- + +def test_reverting_only_the_older_commit_conflicts(adjacent_repo): + """古い方だけを戻すと本当に競合すること。 + + これが競合しないなら、取り消しの作り直しそのものが不要になる。 + """ + r = subprocess.run( + ["git", "revert", "--no-edit", adjacent_repo["c1"]], + cwd=adjacent_repo["repo"], capture_output=True, text=True, + ) + assert r.returncode != 0, "競合しない位置関係になっている(テストの前提が崩れた)" + subprocess.run(["git", "revert", "--abort"], cwd=adjacent_repo["repo"], + capture_output=True, text=True) + + +# ---------- 項目単位で取り消せる場合 ---------- + +@pytest.mark.parametrize("fixture_name", ["distant_repo", "separate_repo"]) +def test_drop_older_item_keeps_the_newer_one(refactor, request, fixture_name): + """独立した変更なら、古い項目だけを取り消して新しい項目を残せること。""" + built = request.getfixturevalue(fixture_name) + state, entry = _state(built) + result = refactor._drop_items(state, entry, ["R1-001"]) + + assert result["mode"] == "item" + assert "line3-by-R1-001" not in _content(built), "取り消した項目の変更が残っている" + assert "R1-002" in _content(built) + _content(built, "bar.py"), \ + "残すはずの項目の変更が消えている" + + by_id = {i["item_id"]: i for i in state["items"]} + assert by_id["R1-001"]["reverted"] is True + # 積み直しで SHA が変わるので、記録も追従していること + head = _git("rev-parse", "HEAD", cwd=built["repo"]).stdout.strip() + assert by_id["R1-002"]["commits"] == [head] + + +def test_drop_newer_item_keeps_the_older_one(refactor, distant_repo): + """新しい項目だけを取り消す向きでも成立すること。""" + state, entry = _state(distant_repo) + assert refactor._drop_items(state, entry, ["R1-002"])["mode"] == "item" + assert "line3-by-R1-001" in _content(distant_repo) + assert "line31-by-R1-002" not in _content(distant_repo) + + +def test_second_drop_after_the_first_still_works(refactor, distant_repo): + """1 回目で積み直した SHA に対して、もう一度取り消せること。 + + 積み直しで SHA が変わるので、記録を更新していないとここで破綻する。 + """ + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001"]) + assert refactor._drop_items(state, entry, ["R1-002"])["mode"] == "item" + assert _content(distant_repo) == "".join(LINES) + + +def test_dropping_is_idempotent(refactor, distant_repo): + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001"]) + head = _git("rev-parse", "HEAD", cwd=distant_repo["repo"]).stdout.strip() + + assert refactor._drop_items(state, entry, ["R1-001"])["mode"] == "skip" + assert _git("rev-parse", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() == head + + +# ---------- 積み直せない場合 ---------- + +def test_adjacent_changes_fall_back_to_the_whole_round(refactor, adjacent_repo): + """隣接する変更は分離できない。退避して全件取り消すこと。 + + 半端な履歴を残すより、決定的な状態へ落とす方が安全である。 + """ + state, entry = _state(adjacent_repo) + result = refactor._drop_items(state, entry, ["R1-001"]) + + assert result["mode"] == "round" + assert _content(adjacent_repo) == "".join(LINES), "着手前の内容へ戻っていない" + assert all(i["reverted"] for i in state["items"]) + assert entry["drops"][-1]["mode"] == "round" + + +def test_dropping_every_item_returns_to_the_base_tree(refactor, distant_repo): + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001", "R1-002"]) + assert _content(distant_repo) == "".join(LINES) + + +def test_history_is_never_rewritten(refactor, distant_repo): + """`--force` を使わずに済むよう、前進だけで戻すこと。""" + state, entry = _state(distant_repo) + before = _git("rev-list", "--count", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() + refactor._drop_items(state, entry, ["R1-001"]) + after = _git("rev-list", "--count", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() + assert int(after) > int(before), "履歴を書き換えている" + assert built_commits_still_reachable(distant_repo) + + +def built_commits_still_reachable(built) -> bool: + """着手前のコミットが履歴から消えていないこと。""" + log = _git("rev-list", "HEAD", cwd=built["repo"]).stdout.split() + return built["c1"] in log and built["base"] in log diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_init.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_init.py index 9e566545..4987b7d6 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_init.py @@ -86,6 +86,9 @@ def fake_sh(cmd, cwd=None, check=True): monkeypatch.setattr(refactor, "_sh", fake_sh) monkeypatch.chdir(origin_repo) monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) + # 認証確認は実際の CLI を起動する。ここでは対象外なので飛ばす + # (確認そのものは `test_init_checks_cli_authentication` で見る)。 + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") refactor.cmd_init(args) return _run @@ -234,3 +237,94 @@ def test_diverged_worktree_stops_the_run(run_init, tmp_path): with pytest.raises(SystemExit): run_init(_args(tmp_path)) + + +# ---------- 語彙と認証 ---------- + +def test_init_records_the_vocabulary_for_the_prompt(run_init, tmp_path, refactor): + """許容値をプロンプトへ列挙できるよう、語彙集合を状態へ残すこと。 + + 手順書の見出しは日本語なので、「語彙に限定する」とだけ書くと読んだ側が + 日本語を語彙と解釈する(実測で gemini の提案 4 件が全件見送りになった)。 + """ + run_init(_args(tmp_path)) + _, state = _state_of(tmp_path) + assert state["vocabulary"]["smells"]["long_method"] == "長すぎるメソッド" + assert "extract_method" in state["vocabulary"]["techniques"] + assert state["vocabulary"]["severities"] == ["minor", "major", "critical"] + # 定義は検証側の 1 箇所だけに置く + assert state["vocabulary"]["smells"] == refactor.SMELLS + + +def _probe_result(refactor, monkeypatch, outcomes): + """認証確認コマンドの結果を差し替える。`{ランタイム: (rc, 出力)}`。""" + def fake_run(cmd, **kwargs): + for runtime, probe in refactor.AUTH_PROBES.items(): + if list(cmd) == list(probe): + rc, out = outcomes.get(runtime, (0, "ok")) + return subprocess.CompletedProcess(cmd, rc, out, "") + raise AssertionError(f"想定外の呼び出し: {cmd}") + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + + +def test_check_auth_passes_when_every_cli_is_logged_in(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {}) + results = refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + assert all(r["ok"] for r in results.values()) + + +def test_check_auth_fails_on_a_non_zero_exit(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {"kiro": (1, "")}) + with pytest.raises(SystemExit) as e: + refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + assert e.value.code == refactor.ABORT + + +def test_check_auth_fails_when_the_output_says_not_logged_in(refactor, monkeypatch): + """終了コード 0 でも未認証を示すことがある(kiro は成否を終了コードで表さない)。""" + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {"kiro": (0, "Not logged in")}) + with pytest.raises(SystemExit): + refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + + +def test_check_auth_fails_when_the_cli_is_missing(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + + def missing(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(refactor.subprocess, "run", missing) + with pytest.raises(SystemExit): + refactor.check_auth(["codex"]) + + +def test_check_auth_can_be_skipped_explicitly(refactor, monkeypatch): + """確認コマンドは CLI の版で変わる。飛ばせる逃げ道を残す。""" + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") + + def never(cmd, **kwargs): + raise AssertionError("認証確認を実行してはいけない") + + monkeypatch.setattr(refactor.subprocess, "run", never) + assert refactor.check_auth(["codex", "gemini"]) == {} + + +def test_init_checks_cli_authentication(refactor, origin_repo, monkeypatch, tmp_path): + """未認証の CLI があれば初期化ごと中断すること。 + + 参加者が 1 人欠けた構成のまま進むと、その者の提案とレビューが無いまま収束する。 + """ + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + monkeypatch.chdir(origin_repo) + monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) + _probe_result(refactor, monkeypatch, {"gemini": (1, "Authentication failed")}) + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: pytest.fail("認証確認より前に gh を呼んでいる"), + ) + with pytest.raises(SystemExit) as e: + refactor.cmd_init(_args(tmp_path)) + assert e.value.code == refactor.ABORT diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py index ccb44961..3074e0fa 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py @@ -383,24 +383,56 @@ def test_self_reported_values_cannot_pass_the_check( assert "テストが成功していません" in state["items"][0]["failure_reason"] -def test_failed_item_commits_are_reverted( - refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts -): - """検証に失敗した項目のコミットを Pull Request に残さない。 +def _drop_env(refactor, monkeypatch, revert_rc=0, pick_rc=0): + """取り消しと積み直しを実際には走らせず、順序と引数を記録する。 - 実装担当は項目ごとに push しているため、状態を `abandoned` にするだけでは - 差分が残り、以後のレビュー対象にも混入する。 + `git rev-parse HEAD` は**直前に積み直したコミット**に応じた値を返す。 + 積み直しで SHA が変わることを、状態の更新まで含めて確かめられるようにする。 """ + calls: list[list[str]] = [] + picked: list[str] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + rc = 0 + if cmd[:2] == ["git", "revert"] and "--abort" not in cmd: + rc = revert_rc + if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: + rc = pick_rc + if rc == 0: + picked.append(cmd[-1]) + return subprocess.CompletedProcess(cmd, rc, "", "conflict" if rc else "") + + def fake_git_out(work, args): + if args[:2] == ["rev-parse", "--verify"]: + return args[-1].replace("^{commit}", "") + if args == ["rev-parse", "HEAD"]: + return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + return "HEAD_BEFORE" + + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + monkeypatch.setattr(refactor, "_git_out", fake_git_out) + pushes: list[list[str]] = [] + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: pushes.append(list(cmd)) or "") + return calls, pushes + + +def _two_item_apply(tmp_path, env_tmp_dir, git_facts): + """1 件成功・1 件失敗の適用結果を用意する。失敗するのは R1-002。""" items = [item(item_id="R1-001"), item(item_id="R1-002")] state_path = _state_with_items(tmp_path, items) env_tmp_dir(state_path) - git_facts({ - "ok111": fact(sha="ok111"), - "bad111": fact(sha="bad111", diff_lines=400, - trailers=trailers(item_id="R1-002")), - "bad222": fact(sha="bad222", diff_lines=400, - trailers=trailers(item_id="R1-002")), - }) + git_facts( + { + "ok111": fact(sha="ok111"), + "bad111": fact(sha="bad111", diff_lines=400, + trailers=trailers(item_id="R1-002")), + "bad222": fact(sha="bad222", diff_lines=400, + trailers=trailers(item_id="R1-002")), + }, + # 履歴は bad222 が最も新しい + in_range=["bad222", "bad111", "ok111"], + ) write_result(state_path, "codex-apply-r1", { "base_sha": "aaa", "items": [ @@ -409,36 +441,181 @@ def test_failed_item_commits_are_reverted( {"item_id": "R1-002", "commits": [{"sha": "bad111"}, {"sha": "bad222"}]}, ], }) + return state_path - calls: list[list[str]] = [] - monkeypatch.setattr( - refactor.subprocess, "run", - lambda cmd, **kw: calls.append(list(cmd)) - or subprocess.CompletedProcess(cmd, 0, "", ""), - ) - pushes: list[list[str]] = [] - monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: pushes.append(cmd) or "") - # git の履歴は bad222 が最も新しい - monkeypatch.setattr( - refactor, "_git_out", - lambda work, args: ("bad222\nbad111\nok111" if args[:1] == ["rev-list"] - else args[-1].replace("^{commit}", "")), - ) + +def test_dropping_an_item_replays_the_kept_items( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """範囲を新しい順に全て戻し、残す項目を古い順に積み直すこと。 + + 失敗した項目のコミット**だけ**を戻すと、あとから同じ箇所を触った別項目の + コミットと必ず競合する。範囲全体の巻き戻しは履歴の逆再生なので競合しない。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, pushes = _drop_env(refactor, monkeypatch) refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) - reverts = [c for c in calls if c[:2] == ["git", "revert"]] - # 新しいコミットから順に戻す - assert [c[-1] for c in reverts] == ["bad222", "bad111"] + reverts = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + picks = [c[-1] for c in calls if c[:2] == ["git", "cherry-pick"]] + assert reverts == ["bad222", "bad111", "ok111"], "範囲を新しい順に全て戻していない" + assert picks == ["ok111"], "残す項目だけを積み直していない" assert pushes, "取り消し後に push していない" for cmd in pushes: - assert "--force" not in cmd + assert "--force" not in cmd and "--no-verify" not in cmd state = read_state(state_path) by_id = {i["item_id"]: i for i in state["items"]} assert by_id["R1-001"]["status"] == "reviewing" assert by_id["R1-002"]["status"] == "abandoned" - assert by_id["R1-002"]["commits"] == ["bad111", "bad222"] + assert by_id["R1-002"]["reverted"] is True + # 積み直しで SHA が変わるので、記録も追従すること + assert by_id["R1-001"]["commits"] == ["new-ok111"] + + +def test_replay_conflict_falls_back_to_whole_round( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """積み直せないときはラウンド全件の取り消しへ退避すること。 + + どの項目を残せるか決められない以上、半端な履歴を残すより全件捨てる方が安全。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, _ = _drop_env(refactor, monkeypatch, pick_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == 2, "全件失敗として次の提案ラウンドへ進むこと" + + assert ["git", "cherry-pick", "--abort"] in calls + assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + state = read_state(state_path) + assert all(i["status"] == "abandoned" for i in state["items"]) + assert state["rounds"][0]["apply"]["applied"] == [] + assert state["rounds"][0]["drops"][-1]["mode"] == "round" + + +def test_revert_failure_aborts_with_the_abort_code( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しに失敗したら「全件失敗」ではなく**中断**として終わること。 + + 2(全件失敗)と同じ扱いにすると、検証を通っていない変更を Pull Request に + 残したまま次の提案ラウンドが始まる。 + """ + _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == refactor.ABORT == 4 + assert ["git", "revert", "--abort"] in calls + assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + + +def test_progress_is_recorded_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しの前に判定を残すこと。中断しても到達点が状態から読める。""" + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + seen: list[list[dict]] = [] + + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + seen.append(read_state(state_path)["rounds"][0].get("apply_progress")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + assert seen, "取り消しが走っていない" + recorded = {p["item_id"]: p["result"] for p in seen[0]} + assert recorded == {"R1-001": "ok", "R1-002": "failed"} + + +def test_pending_push_is_marked_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しへ着手する前に再送信の印を立てること。 + + 取り消しは済んだのに push できずに終わると、検証を通っていない変更が + Pull Request に残り、次の実行は処理済みガードで素通りする。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + marks: list[bool] = [] + + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + marks.append(read_state(state_path)["rounds"][0].get("pending_push")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert marks and marks[0] is True + + +def test_pending_push_is_cleared_after_a_successful_push( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +def test_out_of_scope_commit_fails_the_item( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """指定した範囲の外を触ったコミットを検証で捕まえること。 + + 範囲を必須にした目的(提案の発散と変更の肥大を防ぐ)を、検証へ反映する。 + """ + items = [item(item_id="R1-001")] + state_path = _state_with_items(tmp_path, items) + env_tmp_dir(state_path) + git_facts({"out111": fact( + sha="out111", files=["src/foo.py", "dist/foo.py"], + )}) + write_result(state_path, "codex-apply-r1", { + "base_sha": "aaa", + "items": [{"item_id": "R1-001", "commits": [{"sha": "out111"}]}], + }) + _drop_env(refactor, monkeypatch) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == 2 + state = read_state(state_path) + assert state["items"][0]["out_of_scope"] is True + assert "dist/foo.py" in state["items"][0]["failure_reason"] + + +def test_scope_check_matches_only_on_path_prefix(refactor): + assert refactor.path_in_scope("src/foo.py", ["src"]) + assert refactor.path_in_scope("src", ["src"]) + assert not refactor.path_in_scope("src2/foo.py", ["src"]), "前方一致の取りこぼし" + assert not refactor.path_in_scope("dist/foo.py", ["src"]) + # 範囲が空なら検査しない(指定が無いのに全件落とさない) + assert refactor.out_of_scope_files({"files": ["any.py"]}, []) == [] def test_no_push_when_nothing_was_reverted( diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_proposals.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_proposals.py index 6e5ea1a3..e9882455 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_proposals.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_proposals.py @@ -185,9 +185,9 @@ def test_merge_proposals_command_creates_items( "durations": {}, "reviews": [], }]) env_tmp_dir(state_path) - write_result(state_path, "codex-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "gemini-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "kiro-propose-rf130", {"items": []}) + write_result(state_path, "codex-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "gemini-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": []}) refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -210,7 +210,7 @@ def test_merge_proposals_command_exits_2_when_nothing_adopted( }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": []}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": []}) with pytest.raises(SystemExit) as e: refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -234,9 +234,9 @@ def test_non_object_proposal_result_is_treated_as_empty( "durations": {}, "reviews": [], }]) env_tmp_dir(state_path) - write_result(state_path, "codex-propose-rf130", ["配列で返ってきた"]) - write_result(state_path, "gemini-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "kiro-propose-rf130", {"items": [proposal()]}) + write_result(state_path, "codex-propose-rf130-r1", ["配列で返ってきた"]) + write_result(state_path, "gemini-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": [proposal()]}) refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -259,7 +259,7 @@ def test_merge_proposals_is_idempotent(refactor, tmp_path, env_tmp_dir, no_git): }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": [proposal()]}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": [proposal()]}) args = type("A", (), {"id": 130})() refactor.cmd_merge_proposals(args) @@ -285,7 +285,7 @@ def test_merge_proposals_replays_the_converged_exit_code( }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": []}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": []}) args = type("A", (), {"id": 130})() for _ in range(2): diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_prepare_worktrees.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_prepare_worktrees.py index 08da12aa..14973eaa 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_prepare_worktrees.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_prepare_worktrees.py @@ -250,3 +250,35 @@ def test_empty_destination_is_provisioned(repo): dest.mkdir() _run(repo) assert (dest / "SKILL.md").is_file() + + +# ---------- gemini の読み取り除外 ---------- + +def test_gemini_gets_a_setting_that_allows_reading_the_provisioned_skills(repo): + """gemini は除外設定を**読み取りにも**適用するため、無効にする設定を置く。 + + 置かないと、配置した手順書を `read_file` で一切開けず、 + 語彙を読めないまま提案が語彙外になって全件降格する。 + """ + _run(repo) + settings = repo["root"] / "gemini" / ".gemini" / "settings.json" + assert settings.is_file(), "gemini の設定が置かれていない" + conf = json.loads(settings.read_text(encoding="utf-8")) + # 項目名は gemini の版で変わる。新旧どちらの形式でも書く + assert conf["context"]["fileFiltering"]["respectGitIgnore"] is False + assert conf["context"]["fileFiltering"]["respectGeminiIgnore"] is False + assert conf["fileFiltering"]["respectGitIgnore"] is False + assert conf["fileFiltering"]["respectGeminiIgnore"] is False + + +def test_gemini_settings_are_not_in_the_diff(repo): + _run(repo) + status = _git("status", "--short", cwd=repo["root"] / "gemini") + assert status.stdout.strip() == "", f"差分に現れている: {status.stdout}" + + +def test_only_gemini_gets_the_reading_setting(repo): + """他のランタイムの設定は触らない。""" + _run(repo) + for rt in ("codex", "kiro"): + assert not (repo["root"] / rt / ".gemini").exists() diff --git a/plugins/ndf-shared/skills/cross-refactoring/SKILL.md b/plugins/ndf-shared/skills/cross-refactoring/SKILL.md index 0cd96bc7..bd01328d 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf-shared/skills/cross-refactoring/SKILL.md @@ -37,7 +37,8 @@ allowed-tools: | 役割の分離 | 提案・レビューは**ホストを除く 3 者**、適用は**gemini を除く 3 者**。両者は重なるが一致しない | | レビューの単位 | **提案ラウンドの差分全体**に対して 1 回。項目ごとに回すと CLI 起動回数が採用件数に比例して膨らむ | | 収束しない項目 | **捨てる。** リファクタリングは任意の作業なので、揉める提案を Pull Request に残さない | -| 取り消しの単位 | **改善項目ごと。** 合意済みの項目は残す。そのために 1 手 1 コミットを機械検証する | +| 取り消しの単位 | **改善項目ごと(独立している範囲で)。** 範囲を新しい順に全て戻し、残す項目を積み直す。同一ファイルの隣接行を触る項目どうしは git だけでは分離できないため、そのときは**ラウンド全件へ退避する** | +| 範囲の扱い | `--scope` は**検証にも効く**。範囲外を触ったコミットを含む項目は失敗になる。生成物の同期は進行側が収束後にまとめて行う | | 検証の情報源 | **git と実際のテスト実行。** 結果ファイルの申告は検証に使わない(書き換えるだけで通る検査にしない) | | 投稿 | **AI 自身が `gh api` で投稿する。** ホストの作業文脈に差分やレビュー本文を載せない | | 状態の永続化 | `/.cross_refactoring/cross-refactoring-rf<番号>-state.json` に集約。中断・再開可能 | @@ -48,7 +49,7 @@ allowed-tools: | 引数 | 意味 | 既定 | | --- | --- | --- | | `[PR番号]` | 対象の Pull Request | 必須 | -| `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須** | 必須 | +| `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須。** 検証にも効くので、現状固定テストの置き場所も含める | 必須 | | `--host claude\|codex\|kiro` | ホストの明示指定。未指定時は環境変数から推定 | 推定 | | `--model RT=MODEL` | ランタイムごとのモデル。繰り返し指定できる | CLI の既定 | | `--baseline-test CMD` | 着手前と各コミットで実行するテスト。**振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須** | 必須 | @@ -59,7 +60,7 @@ allowed-tools: | `--test-timeout SEC` | テスト 1 回あたりの上限秒数。超えたら失敗として扱う | `900` | ```text -/ndf:cross-refactoring 130 --scope src/services --baseline-test "pytest -q" +/ndf:cross-refactoring 130 --scope src/services tests/services --baseline-test "pytest -q" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=opus-5 /ndf:cross-refactoring 130 --scope src --host codex --max-outer-rounds 1 ``` @@ -88,6 +89,20 @@ allowed-tools: ## 前提 - `gh` CLI が認証済みで、`jq` と `uv`(または Python 3.10 以上)が使える +- 参加する CLI が**すべてログイン済み**である。`init` が認証状態を確認し、1 つでも + 未認証なら中断する(未認証の CLI は起動から 15 秒で終わり、結果を残さないまま + 担当から脱落するため、確認しないと参加者が欠けた構成のまま進行する) + + | ランタイム | 確認コマンド | + | --- | --- | + | claude | `claude auth status` | + | codex | `codex login status` | + | gemini | `gemini --skip-trust -p ping --output-format text` | + | kiro | `kiro-cli whoami` | + + 確認コマンドは CLI の版で変わりうる。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` で + 飛ばせる(飛ばしたことは出力に残る) + - ホストごとに次の CLI が使える(不足していると初期化時に失敗する) | ホスト | 必要な CLI | @@ -139,7 +154,19 @@ PLUGIN_ROOT="${PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}" SCRIPTS="$PLUGIN_ROOT/skills/cross-refactoring/scripts" LIB="$PLUGIN_ROOT/skills/cross-review/scripts/lib" -eval "$("$SCRIPTS/refactor.py" init "$PR" --scope $SCOPE \ +# **中断(終了コード 4)は握り潰さない。** 取り消しに失敗した状態を「全件失敗」と +# 同じ扱いにすると、検証を通っていない変更を Pull Request に残したまま次の提案が +# 始まる。判定に使う終了コードだけを呼び出し側へ返し、それ以外は進行ごと止める。 +rf() { + "$SCRIPTS/refactor.py" "$@"; local rc=$? + if [ $rc -eq 4 ]; then + echo "❌ cross-refactoring を中断しました(refactor.py $1)" >&2 + exit 4 + fi + return $rc +} + +eval "$(rf init "$PR" --scope $SCOPE \ --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS)" @@ -147,18 +174,18 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" "$SCRIPTS/prepare-worktrees.sh" "$ID" while :; do # 提案ラウンドの繰り返し - eval "$("$SCRIPTS/refactor.py" start-round "$ID")" || break + eval "$(rf start-round "$ID")" || break # 終了コード 1 = 繰り返し終了 for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ - --stem-template '{agent}-propose-rf{id}' - "$SCRIPTS/refactor.py" merge-proposals "$ID" || break # 終了コード 2 = 採用 0 件 + --stem-template "{agent}-propose-rf{id}-r$ROUND" + rf merge-proposals "$ID" || break # 終了コード 2 = 採用 0 件 "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-apply-r$ROUND" --timeout 3600 - "$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" || continue # 全件失敗 + rf merge-apply "$ID" "$ROUND" || continue # 終了コード 2 = 全件失敗 # 適用後の状態をレビュー担当へ見せるため、読み取り用を同期する "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" @@ -169,23 +196,37 @@ while :; do # 提案ラウンドの繰り返 done "$LIB/monitor.py" "$ID" --agents "$REVIEWERS_CSV" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-review-r$ROUND" - "$SCRIPTS/refactor.py" judge-review "$ID" "$ROUND"; rc=$? + rf judge-review "$ID" "$ROUND"; rc=$? [ $rc -eq 0 ] && break # 2 者とも承認 [ $rc -eq 3 ] && continue # 形式不正 — 差し戻して再レビュー - if "$SCRIPTS/refactor.py" should-abandon "$ID" "$ROUND"; then - "$SCRIPTS/refactor.py" abandon-items "$ID" "$ROUND"; break + if rf should-abandon "$ID" "$ROUND"; then + rf abandon-items "$ID" "$ROUND"; break fi "$SCRIPTS/launch-cli.sh" "$IMPL" fix "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-fix-r$ROUND" - "$SCRIPTS/refactor.py" merge-fix "$ID" "$ROUND" + rf merge-fix "$ID" "$ROUND" # 修正後の状態を再レビューさせる。同期しないと古い差分を評価してしまう "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" done - "$SCRIPTS/refactor.py" advance "$ID" || break + rf advance "$ID" || break done + +# 収束後にまとめて生成物を同期する(**進行側の責務**)。編集元から配布物を生成する +# 規約を持つリポジトリでは、実装担当に同期させると範囲外の変更が生まれる。 +# 同期が要るなら、ここで生成してから Step 7 の最終ゲートへ渡す。 ``` +### 終了コード + +| コード | 意味 | 進行 | +| --- | --- | --- | +| 0 | 正常 | 続ける | +| 1 | 繰り返しの終了(`start-round` / `advance`) | 抜ける | +| 2 | 判定の結果(採用 0 件 / 全件失敗 / 変更要求 など) | 各コマンドの表に従う | +| 3 | レビュー結果の形式不正 | 差し戻して再レビュー | +| **4** | **中断**(取り消しの失敗、認証切れ、範囲を確定できないなど) | **進行ごと止める** | + 続けて **Step 7** で `/ndf:cross-review ` を実行する。レビューはラウンド単位なので、 **ラウンドを跨いだ整合はここで見る**。収束したら Draft を解除し、 `refactor.py report "$ID" --metrics` の出力を報告する。 @@ -199,6 +240,8 @@ done | ホストのサブエージェントで適用する | ホストの作業文脈に差分が載り、実装者とレビュー担当の独立性が崩れる | | `launch-cli.sh` に「ホストなら起動しない」分岐を入れる | ホストは適用担当として起動しうる。分岐はランタイム名だけで行う | | `--scope` を省く | 提案が発散し、Pull Request が肥大する | +| 実装担当に生成物を同期させる | 範囲外の変更が生まれ、差分予算を超える。同期は進行側が収束後にまとめて行う | +| 取り消しの失敗を「全件失敗」として次のラウンドへ進む | 検証を通っていない変更が Pull Request に残る。終了コード 4 は必ず進行ごと止める | | `--dry-run` の出力を実行結果と混同する | 確認用なので git も状態ファイルも触らない。進行は 1 歩も進まない | | 複数の改善項目を 1 コミットにまとめる | 取り消し範囲が項目単位で決まらなくなる。適用結果の検証で失敗になる | | 結果ファイルの申告を検証の材料にする | 実装担当は報告する側。JSON を書き換えるだけで通る検査は機械検証ではない | diff --git a/plugins/ndf-shared/skills/cross-refactoring/docs/01-state-and-propose.md b/plugins/ndf-shared/skills/cross-refactoring/docs/01-state-and-propose.md index bfbb6735..487a9b4c 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/docs/01-state-and-propose.md +++ b/plugins/ndf-shared/skills/cross-refactoring/docs/01-state-and-propose.md @@ -37,7 +37,16 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 使うと古い HEAD に対して提案・適用してしまう。早送りできない(履歴が分かれた) ときは中断する。`git fetch` に失敗したときも中断する(古い `origin/` へ 早送りして「同期したつもり」になるのを防ぐ) -5. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 +5. **認証状態の確認** — 参加する CLI を 1 つずつ確認し、未認証なら**初期化ごと中断する** + (終了コード 4)。存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、 + 結果ファイルを残さないまま担当から脱落するが、それでも初期化は成功として扱われるため、 + **参加者が 1 人欠けた構成のまま最後まで進んでしまう**(実測)。作業ディレクトリを + 作る前に確認する。確認コマンドは CLI の版で変わりうるので `NDF_SKIP_AUTH_CHECK=1` + で飛ばせるが、飛ばしたことは必ず出力へ残す +6. **語彙の受け渡し** — 検証側が持つスメル・手法・重要度の集合を状態ファイルの + `vocabulary` へ書く。提案プロンプトはここから**許容値をそのまま列挙する**。 + 定義を 1 箇所に保ったまま、読ませ方の不確実性を減らすためである +7. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 壊れた状態から始めると、壊したのか元から壊れていたのか区別できない。 この引数は**必須**である。振る舞いが変わっていないことを示す手段が無い書き換えは、 `refactoring` Skill の定義からして構造改善ではない @@ -121,6 +130,30 @@ gemini は NDF の配布先ではないため「標準の配置先」を持た **対象リポジトリ本体を書き換える**ことになり、「配置は作業ディレクトリの中だけで完結 させる」という前提を破る。 +#### gemini は除外を読み取りにも適用する + +全件無視をそのまま効かせると、**gemini は配置した手順書を 1 つも開けない**。 + +```text +Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +is ignored by configured ignore patterns. +``` + +手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と +書いている前提が崩れるため、gemini の作業ディレクトリへ +`.gemini/settings.json` を置き、**読み取り側の除外だけを無効にする**。 + +```json +{ + "context": { "fileFiltering": { "respectGitIgnore": false, "respectGeminiIgnore": false } }, + "fileFiltering": { "respectGitIgnore": false, "respectGeminiIgnore": false } +} +``` + +設定の項目名は gemini の版で変わるため、**新旧どちらの形式でも書く**(0.55.1 で +読み取り成功を確認)。設定自身も差分に出さないよう `.gemini/` ごと無視する。 +配置は**手順書を配る前**に行う。gemini は起動時に 1 度だけ設定を読むためである。 + ### 読ませ方(明示パスを必ず書く) Skill を配置しても、**本文を読むかどうかはランタイムによって違う**。 @@ -150,7 +183,7 @@ for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ - --stem-template '{agent}-propose-rf{id}' + --stem-template "{agent}-propose-rf{id}-r$ROUND" ``` 3 CLI を並列で起動し、同一のプロンプトで提案させる。**提案フェーズにホストは現れない** @@ -159,6 +192,29 @@ done 提出形式は [prompts/propose.md](../prompts/propose.md) にある。 +### 結果ファイル名にラウンド番号を入れる + +CLI の起動時に同名の結果ファイルを消すため、**提案の結果ファイル名にもラウンド番号が +要る**。番号を入れないと、2 巡目の提案が始まった時点で 1 巡目の提案内容が失われる。 +統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは復元できない**。 + +| フェーズ | ファイル名 | +| --- | --- | +| 提案 | `<ランタイム>-propose-rf-r<ラウンド>-result.json` | +| 適用 | `<ランタイム>-apply-r<ラウンド>-result.json` | +| レビュー | `<ランタイム>-review-r<ラウンド>-result.json` | + +### 語彙は列挙して渡す + +提案プロンプトは `smell` / `technique` / `severity` の**許容値を列挙する**。 +「手順書の語彙に限定する」とだけ書くと、手順書の見出しが日本語であるため、読んだ側が +日本語を語彙と解釈する。実測では gemini の提案 4 件が全て日本語の語彙で返り、 +内容は妥当だったにもかかわらず語彙外の降格規則で全件が見送りになった。 + +列挙の元は `refactor.py` の `SMELLS` / `TECHNIQUES`(検証側が持つ集合)である。 +`init` が状態ファイルの `vocabulary` へ書き、`launch-cli.sh` が読んで差し込む。 +**同じ一覧を 2 か所に書かない。** + ## Step 3: 提案のマージ ```bash diff --git a/plugins/ndf-shared/skills/cross-refactoring/docs/02-apply-and-review.md b/plugins/ndf-shared/skills/cross-refactoring/docs/02-apply-and-review.md index 93146ae1..e9b460b8 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/docs/02-apply-and-review.md +++ b/plugins/ndf-shared/skills/cross-refactoring/docs/02-apply-and-review.md @@ -8,9 +8,13 @@ "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ --stem-template "{agent}-apply-r$ROUND" --timeout 3600 -"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 終了コード 2 = 全件失敗 +"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 2 = 全件失敗 / 4 = 中断 ``` +終了コード 2 と 4 を**必ず区別する**。同じ扱いにすると、取り消しに失敗した状態を +「全件失敗」として次の提案ラウンドへ進み、検証を通っていない変更が Pull Request に +残ったまま新しい提案が始まる(実測)。 + 実装担当を**1 ラウンド 1 回**起動し、採用した改善項目を優先度順に**直列適用**させる。 並列適用はしない(同一ブランチへの同時コミットは競合とレビュー単位の曖昧化を招く)。 @@ -36,8 +40,38 @@ | テストが無い経路は先に現状固定テスト | `git show --name-only` | `test_gap` が真の項目は、先頭コミットがテストの置き場所を触っている | | 項目の分離 | git のトレーラー | 各コミットの `Item-Id` がその項目と一致する。複数の項目を 1 コミットにまとめたら失敗 | | 差分予算 | `git show --numstat` | 実差分の合計が `estimated_diff_lines` の 2 倍を超えたら失敗(範囲の逸脱) | +| 対象範囲の遵守 | `git show --name-only` | 触ったファイルが全て `--scope` の中にある。1 つでも外なら失敗 | | 機能変更の混入なし | — | 機械判定は不可能。レビュー観点に委ねる | +#### 範囲の指定は検証にも効かせる + +`--scope` を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を検証へ +反映しないと目的を果たせない。実測では、編集元から配布物を生成する規約に従った結果として +範囲外の 3 系統が変更され、差分が 4 倍に膨らんで差分予算を超えた。実装担当の判断自体は +リポジトリの規約に沿っており、**規約と範囲の指定が衝突していた**のが原因である。 + +そこで責務を分ける。 + +| 誰が | 何を | +| --- | --- | +| 実装担当 | `--scope` の中だけを変更する。生成物・配布物の同期はしない | +| 進行側(ホスト) | 収束後にまとめて生成物を同期する | + +判定は**前方一致だけ**で行い、除外規則は持たない。規則を書けるようにすると、 +規則を 1 行足すだけで範囲の検査を骨抜きにできる。 + +そのため **`--scope` には現状固定テストの置き場所も含める**。含めないと、 +`test_gap` が真の項目で「テストを先に足せ」と「範囲外を触るな」が両立しなくなり、 +その項目は必ず失敗する。 + +```bash +# ❌ テストの置き場所が入っていない +--scope src/services + +# ✅ 直す対象とテストの置き場所を両方入れる +--scope src/services tests/services +``` + テストの実行はコミットごとに `git checkout --detach ` して行い、終わったら 必ず元のブランチへ戻す。1 ラウンドの採用上限があるため実行回数は数回に収まり、 CLI の起動コストに比べれば無視できる。 @@ -101,9 +135,28 @@ Pull Request に残る。**都合の悪い変更を申告しないだけで検 **1 件の失敗でラウンドを止めない。** 失敗した項目だけを見送りにして、残りは採用する。 全件失敗のときだけ終了コード 2 を返し、次の提案ラウンドへ進む。 -**失敗した項目のコミットはその場で取り消して push する。** 実装担当は項目ごとに -push しているため、状態を `abandoned` にするだけでは差分が Pull Request に残り、 -以後のレビュー対象にも混入する。何が消えるかを先に見たいときは `--dry-run` を付ける。 +#### 判定はその都度記録する + +**項目ごとの判定が出るたびに状態ファイルへ保存する。** まとめて最後に保存すると、 +取り消しの途中で中断したときに適用の記録が一切残らない。実測では 14 件の適用コミットと +3 件の取り消しコミットが実在するのに、状態ファイルは全項目 `pending` / コミット 0 件の +ままだった。**どのコミットが検証を通ったのかを状態から復元できず、同じ手順を叩き直しても +再開できない。** 再開可能性は収束ループの前提なので、ここが崩れると復帰手段が無くなる。 + +記録先は `rounds[].apply_progress`(項目 ID / 判定 / 理由 / コミット)である。 + +#### 取り消しは判定が出そろってからまとめて行う + +**失敗した項目のコミットを Pull Request に残さない。** 実装担当は項目ごとに push して +いるため、状態を `abandoned` にするだけでは差分が残り、以後のレビュー対象にも混入する。 +何が消えるかを先に見たいときは `--dry-run` を付ける。 + +ただし**項目ごとにその場で戻してはならない**。詳細は +[取り消しは巻き戻して積み直す](#取り消しは巻き戻して積み直す)を参照する。 + +**取り消しへ着手する前に `pending_push` を立てる。** 取り消しは済んだのに push できずに +終わると、Pull Request 側には未検証の差分が残るのに、次の実行は処理済みガードで +素通りしてしまう。印があれば、次の実行が判定より先に再送信する。 ### コミットトレーラーの形式 @@ -255,10 +308,10 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 修正ラウンドが上限に達したら、**未解決の指摘が紐づく改善項目だけ**を取り消す。 指摘の無い項目と解決済みの項目は Pull Request に残す。 -- 取り消しは**git の履歴から新しい順に並べ直して** `git revert` する。申告された - 順序は信用しない。古いコミットから戻すと後続の取り消しが競合して止まる +- 取り消しは**範囲を新しい順に全て戻してから、残す項目を積み直す**(次節) - 取り消しに失敗したら、**着手前の HEAD まで戻してから**中断する。先行して成功した - 取り消しだけが履歴に残ると、再実行で不整合になって進めなくなる + 取り消しだけが履歴に残ると、再実行で不整合になって進めなくなる。中断は + **終了コード 4** で表し、「全件失敗」(2)と区別する - **保存してから push する。** 逆順にすると、push の失敗時に取り消しはローカルへ 残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう - push の前に `pending_push` を立て、成功したら消す。失敗したまま終わっても、 @@ -275,6 +328,63 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 修正は必須だが、リファクタリングは任意の作業なので、揉める提案を Pull Request に 残さない方が安全である。 +### 取り消しは巻き戻して積み直す + +**項目のコミットだけを新しい順に戻す方法では足りない。** その並べ替えは同じ項目に属する +コミットの中でしか働かず、取り消し対象より新しい**別項目**のコミットが同じ箇所を触って +いると必ず競合する。実測では、採用した 5 件のうち 4 件が同一ファイルの隣接領域を変更して +おり、取り消しが競合して進行が止まった。 + +```text +❌ R1-002 のコミット ea3209c を取り消せませんでした: error: could not revert ea3209c... +``` + +そこで次の順で行う。 + +```mermaid +flowchart LR + A["範囲 base..HEAD を
新しい順に全て revert"] --> B["残す項目のコミットを
古い順に cherry-pick"] + B -->|成功| C["項目単位の取り消し完了"]:::ok + B -->|競合| D["着手前 HEAD へ reset
範囲を全て revert"] --> E["ラウンド全件を取り消し"]:::stop + + classDef ok fill:#dfd,stroke:#383 + classDef stop fill:#fdd,stroke:#933 +``` + +- 範囲全体を新しい順にたどる取り消しは**履歴の逆再生**なので競合しない。競合するのは + 「一部のコミットだけを飛ばして戻す」ときである +- 積み直しの対象は**残す項目に属するコミットだけ**。過去の取り消しコミットのように + どの項目にも属さないものは積み直さない +- **積み直しで SHA が変わる。** 状態ファイルの `items[].commits` を新しい SHA へ + 更新する。更新しないと、次の取り消しが履歴に無い SHA を指す +- `git push --force` は使わない。履歴の書き換えではなく、**revert と cherry-pick による + 前進だけ**で行う + +#### 隣接する変更は分離できない + +**項目単位で取り消せるのは、項目どうしの変更が独立しているときだけである。** +取り消す側と残す側が同一ファイルの隣接行を触っていると、積み直しの +`git cherry-pick` も競合する。取り消した側の行が消えることで、残す側のパッチが +前提にしている文脈も消えるためで、これは git だけでは決められない。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル | 項目単位 | +| 同一ファイルの離れた行 | 項目単位 | +| 同一ファイルの隣接行 | **ラウンド全件へ退避** | + +退避したときは着手前の状態まで戻し、ラウンドの全項目を見送る。半端な履歴を残すより、 +決定的な状態へ落とす方が安全である。退避したことは `rounds[].drops[].mode` に +`round` として残るので、頻度は報告から読める。 + +実測(Pull Request #118)では採用 5 件のうち 4 件が同一ファイルの隣接領域を変更して +いた。**この構成では退避が普通に起こる**と見込んでおく。項目単位を保ちたいなら、 +`--max-items-per-round` を下げるか、`--scope` を狭めて 1 ラウンドで同じファイルの +近い場所を複数触らせないようにする。 + +範囲の起点を記録していない状態ファイル(旧版)では積み直せないため、従来どおり +項目のコミットだけを新しい順に戻す。この経路では取り消し自体が競合しうる。 + ## Step 7: 提案ラウンドの収束と最終ゲート ```bash @@ -292,7 +402,11 @@ GitHub 側を取得できなかったときは、申告を採用せず**未解 重複率は `path` + `symbol` + `smell` の集合比較で求める。同じ提案が毎ラウンド出続けて 終わらない状態を検知するためである。 -終了後は **`/ndf:cross-review `** で Pull Request 全体を承認収束にかける。 +終了後、生成物の同期が要るリポジトリでは**ここで進行側がまとめて同期する**。 +実装担当に同期させると範囲外の変更が生まれ、差分予算にも影響する(Step 4 の +「範囲の指定は検証にも効かせる」を参照)。 + +続けて **`/ndf:cross-review `** で Pull Request 全体を承認収束にかける。 レビューはラウンド単位なので、**ラウンドを跨いだ整合はここで見る**。 ## Step 8: 報告 diff --git a/plugins/ndf-shared/skills/cross-refactoring/prompts/apply.md b/plugins/ndf-shared/skills/cross-refactoring/prompts/apply.md index 0db2b60f..ad7fac79 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/prompts/apply.md +++ b/plugins/ndf-shared/skills/cross-refactoring/prompts/apply.md @@ -56,6 +56,11 @@ Impl-Model: $RF_MODEL - **`git push --force` と `--no-verify` を使わない** - 作業ディレクトリの外を触らない +- **対象範囲(`$RF_SCOPE`)の外にあるファイルを 1 つも変更しない。** 範囲外を触った + コミットを含む項目は検証で失敗し、取り消されます +- **生成物・配布物の同期をしない。** このリポジトリに「編集元から配布物を生成する」 + 規約があっても、同期は**進行側が収束後にまとめて行う**責務です。ここで同期すると + 範囲外の変更が生まれ、差分予算も超えます - **機能変更を混ぜない。** 振る舞いを変える修正が必要だと分かったら、その項目は 適用せず `status` を `skipped` にして理由を書く - 提案された手順の範囲を超えない。ついでの整理をしない diff --git a/plugins/ndf-shared/skills/cross-refactoring/prompts/fix.md b/plugins/ndf-shared/skills/cross-refactoring/prompts/fix.md index df2814e7..b28b967d 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/prompts/fix.md +++ b/plugins/ndf-shared/skills/cross-refactoring/prompts/fix.md @@ -57,6 +57,9 @@ Impl-Model: $RF_MODEL - **`git push --force` と `--no-verify` を使わない** - 作業ディレクトリの外を触らない +- **対象範囲(`$RF_SCOPE`)の外にあるファイルを 1 つも変更しない。** 範囲外を触った + 修正コミットがあると、その修正ラウンドの範囲ごと取り消されます +- **生成物・配布物の同期をしない。** 同期は進行側が収束後にまとめて行います - 指摘に無い箇所を「ついでに」直さない。ラウンドの差分が膨らみ、 どの変更がどの指摘に対応するのか追えなくなる diff --git a/plugins/ndf-shared/skills/cross-refactoring/prompts/propose.md b/plugins/ndf-shared/skills/cross-refactoring/prompts/propose.md index 2127e31b..cc299fee 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/prompts/propose.md +++ b/plugins/ndf-shared/skills/cross-refactoring/prompts/propose.md @@ -20,6 +20,24 @@ $RF_SKILL_BLOCK $RF_EXCLUDED +## 語彙(この値だけを使う) + +`smell` と `technique` には**下の識別子をそのまま**書いてください。日本語の説明は +意味の対応を示すためのもので、**出力に日本語を書くと語彙外**として扱われ、 +自動的に最低の重要度へ降格し、しきい値で落ちます。 + +### `smell` に書ける値 + +$RF_VOCAB_SMELLS + +### `technique` に書ける値 + +$RF_VOCAB_TECHNIQUES + +### `severity` に書ける値 + +$RF_VOCAB_SEVERITIES + ## やること 1. 対象範囲のコードを読み、**調べる価値がある兆候**を探す @@ -54,9 +72,8 @@ $RF_EXCLUDED ``` - `path` はリポジトリ相対、`symbol` は関数・メソッド・クラスの名前 -- `smell` と `technique` は**手順書の語彙に限定**する。語彙外の値は自動で最低の - 重要度へ降格され、しきい値で落ちます -- `severity` は `critical` / `major` / `minor` のいずれか +- `smell` / `technique` / `severity` は**上に列挙した識別子のいずれか**。 + 列挙にない値(日本語を含む)は自動で最低の重要度へ降格され、しきい値で落ちます - `path` + `symbol` + `smell` が同じ提案は 1 件へ統合されます。**他のランタイムと 合意した提案ほど優先される**ので、独自性を狙わず素直に挙げてください diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/launch-cli.sh b/plugins/ndf-shared/skills/cross-refactoring/scripts/launch-cli.sh index 1f221e20..c2d7468c 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/launch-cli.sh +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/launch-cli.sh @@ -45,7 +45,10 @@ MAX_ITEMS=$(jq -r '.max_items_per_round' "$STATE") case "$PHASE" in propose) - STEM=$TMP_DIR/$RUNTIME-propose-rf$ID + # **提案にもラウンド番号を入れる。** 起動時に同名の結果ファイルを消すため、 + # 番号が無いと 2 巡目の提案が 1 巡目の内容を消してしまう。 + [ "$ROUND" -ge 1 ] 2>/dev/null || { echo "propose には ROUND が必要です" >&2; exit 1; } + STEM=$TMP_DIR/$RUNTIME-propose-rf$ID-r$ROUND WORKDIR=$ROOT/$RUNTIME ;; apply|fix) @@ -112,7 +115,21 @@ export RF_MODEL=${MODEL:-default} RF_WORKDIR=$WORKDIR RF_STEM=$STEM export RF_SCOPE=$SCOPE RF_HEAD_BRANCH=$HEAD_BRANCH RF_BASE_BRANCH=$BASE_BRANCH export RF_BASELINE_TEST=$BASELINE_TEST RF_MAX_ITEMS=$MAX_ITEMS export RF_SKILL_BLOCK=$SKILL_BLOCK RF_EXCLUDED=$EXCLUDED + +# 語彙の許容値。**手順書を読ませるだけでは足りない。** 手順書の見出しは日本語なので、 +# 「語彙に限定する」とだけ書くと読んだ側が日本語を語彙と解釈し、語彙外の降格規則で +# 全件が見送りになる(実測)。検証側が持つ集合を状態ファイル経由で受け取り、 +# **許容値をそのまま列挙する**。 +VOCAB_SMELLS=$(jq -r '(.vocabulary.smells // {}) | to_entries[] | "- `\(.key)` — \(.value)"' "$STATE") +VOCAB_TECHNIQUES=$(jq -r '(.vocabulary.techniques // {}) | to_entries[] | "- `\(.key)` — \(.value)"' "$STATE") +VOCAB_SEVERITIES=$(jq -r '(.vocabulary.severities // []) | map("`" + . + "`") | join(" / ")' "$STATE") +[ -n "$VOCAB_SMELLS" ] || VOCAB_SMELLS="(状態ファイルに語彙がありません。手順書の語彙に従うこと)" +[ -n "$VOCAB_TECHNIQUES" ] || VOCAB_TECHNIQUES="(同上)" +[ -n "$VOCAB_SEVERITIES" ] || VOCAB_SEVERITIES="\`critical\` / \`major\` / \`minor\`" + export RF_ITEMS=$ITEMS_JSON RF_TMP_DIR=$TMP_DIR +export RF_VOCAB_SMELLS=$VOCAB_SMELLS RF_VOCAB_TECHNIQUES=$VOCAB_TECHNIQUES +export RF_VOCAB_SEVERITIES=$VOCAB_SEVERITIES # 雛形は `${RF_*}` を展開するだけの素の Markdown。コマンド置換は展開しない # (プロンプト本文に `$(...)` や backtick が現れても実行させないため)。 diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/prepare-worktrees.sh b/plugins/ndf-shared/skills/cross-refactoring/scripts/prepare-worktrees.sh index ea0b05ce..c13afe38 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/prepare-worktrees.sh +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/prepare-worktrees.sh @@ -131,9 +131,41 @@ provision_skill() { # 代わりに、配置したディレクトリ自身へ全件無視の `.gitignore` を置く。自分自身も # 無視されるため差分に現れず、他の未追跡ファイルには影響しない。 ignore_dir() { + mkdir -p "$1" printf '*\n' > "$1/.gitignore" } +# gemini は**除外設定を読み取りにも適用する**。上の全件無視をそのまま効かせると、 +# 配置した手順書を `read_file` で一切開けない。 +# +# Error executing tool read_file: File path '.../.gemini/skills/refactoring/SKILL.md' +# is ignored by configured ignore patterns. +# +# 手順書自身が「兆候と手法の語彙を読ませないと提案が語彙外になって全件降格する」と +# 書いている前提が崩れるため、**作業ディレクトリ限定で**読み取り側の除外を無効にする。 +# 設定の項目名は gemini の版で変わるので、新旧どちらの形式でも書く(0.55.1 で確認)。 +configure_gemini_reading() { + local base=$1 + local dir="$base/.gemini" + mkdir -p "$dir" + cat > "$dir/settings.json" <<'GEMINI_SETTINGS_EOF' +{ + "context": { + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } + }, + "fileFiltering": { + "respectGitIgnore": false, + "respectGeminiIgnore": false + } +} +GEMINI_SETTINGS_EOF + # 設定そのものも差分に出さない。`.gemini/` ごと無視する。 + printf '*\n' > "$dir/.gitignore" +} + HEAD_SHA=$(git rev-parse "origin/$HEAD_BRANCH" 2>/dev/null || git -C "$WORK" rev-parse HEAD) @@ -160,6 +192,9 @@ MISSING=() CONFLICT=() for rt in "${RUNTIMES[@]}"; do + # 配置より先に置く。gemini は起動時に 1 度だけ設定を読むため、 + # 手順書を配ってから設定を書いても間に合う保証がない。 + [ "$rt" = "gemini" ] && configure_gemini_reading "$ROOT/$rt" entry='{}' for name in "${REQUIRED_SKILLS[@]}"; do status=$(provision_skill "$ROOT/$rt" "$rt" "$name") diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 082dc773..74a99d3e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -48,9 +48,19 @@ import models as models_lib # noqa: E402 import statefile # noqa: E402 -die = statefile.die info = statefile.info +# 中断の終了コード。**「全件失敗」(2)と区別する。** 進行スクリプトは 2 なら次の +# 提案ラウンドへ進み、4 なら進行そのものを止める。区別しないと、取り消しに失敗した +# 状態を「全件失敗」として握り潰し、**検証を通っていない変更を Pull Request に +# 残したまま**次の提案が始まる(実測)。 +ABORT = 4 + + +def die(msg: str, code: int = ABORT) -> None: + """中断して終了する。既定は「中断」を表す終了コード。""" + statefile.die(msg, code) + # ---------------- 語彙 ---------------- # スメルと手法の語彙は `refactoring` Skill の references と 1 対 1 で対応させる。 @@ -101,6 +111,25 @@ SEVERITY_ORDER = {"unknown": 0, "minor": 1, "major": 2, "critical": 3} DEFAULT_SEVERITY_THRESHOLD = "minor" +# 提案が名乗ってよい重要度。`unknown` は降格先なので含めない。 +SEVERITIES: tuple[str, ...] = tuple(s for s in SEVERITY_ORDER if s != "unknown") + + +def vocabulary() -> dict[str, Any]: + """提案プロンプトへ**そのまま列挙する**ための語彙集合。 + + 手順書の見出しは日本語なので、「語彙に限定する」とだけ書くと読んだ側が + 日本語を語彙と解釈する(実測では gemini の提案 4 件が全て日本語で返り、 + 語彙外の降格規則により全件見送りになった)。**検証側が持つ集合をそのまま + 渡す**ことで、許容値の定義を 1 箇所に保ったまま列挙できる。 + """ + return { + "smells": dict(SMELLS), + "techniques": dict(TECHNIQUES), + "severities": list(SEVERITIES), + } + + # 適用と修正のコミットに必須のトレーラー。1 つでも欠けたら当該項目を失敗にする。 # 自由文で「codex が実装」と書かせると集計に使えないため、必ずトレーラー形式にする。 REQUIRED_TRAILERS = ("Item-Id", "Round", "Impl-Runtime", "Impl-Model") @@ -122,6 +151,25 @@ # 差し戻しを無限に繰り返すと、形式を満たせないランタイムでループが止まらなくなる。 MAX_INVALID_REVIEWS = 1 +# 認証状態の確認コマンド。**CLI の存在確認だけでは足りない。** 未認証の CLI は +# 起動から 15 秒で終わり、結果ファイルを残さないまま担当から脱落する(実測)。 +# それでも初期化は成功として扱われるため、参加者が 1 人欠けた構成のまま進行する。 +AUTH_PROBES: dict[str, tuple[str, ...]] = { + "claude": ("claude", "auth", "status"), + "codex": ("codex", "login", "status"), + # gemini には認証確認の副コマンドが無い。最小のプロンプトで疎通を見る。 + # 作業ディレクトリの信頼判定に引っ掛からないよう `--skip-trust` を付ける。 + "gemini": ("gemini", "--skip-trust", "-p", "ping", "--output-format", "text"), + "kiro": ("kiro-cli", "whoami"), +} +AUTH_PROBE_TIMEOUT = 120 + +# **終了コード 0 でも未認証を示すことがある。** kiro は成否を終了コードで表さない。 +UNAUTHENTICATED_MARKERS = ( + "not logged in", "not authenticated", "authentication failed", + "login required", "unauthorized", "please log in", +) + # ---------------- パス解決 ---------------- @@ -203,9 +251,15 @@ def _result_path(state: dict[str, Any], runtime: str, stem: str) -> pathlib.Path def stem_for(runtime: str, phase: str, state_id: int, round_no: Optional[int] = None) -> str: - """一時ファイル名の骨格。監視スクリプトの `--stem-template` と揃える。""" + """一時ファイル名の骨格。監視スクリプトの `--stem-template` と揃える。 + + **提案にもラウンド番号を入れる。** CLI の起動時に同名の結果ファイルを消すため、 + 番号が無いと 2 巡目の提案が始まった時点で 1 巡目の提案内容が失われる。 + 統合後の採否は状態ファイルに残るが、**各ランタイムが何をどう提案したかは + 復元できなくなる**(実測)。 + """ if phase == "propose": - return f"{runtime}-propose-rf{state_id}" + return f"{runtime}-propose-rf{state_id}-r{round_no}" return f"{runtime}-{phase}-r{round_no}" @@ -364,6 +418,51 @@ def duplicate_rate( # **git と実際のテスト実行**から取る。結果ファイルから使うのは「どのコミットが # どの項目のものか」という対応付けの手がかりだけである。 +def path_in_scope(path: str, scope: Iterable[str]) -> bool: + """`path` が対象範囲の中にあるか。判定は**前方一致だけ**で行う。 + + 除外規則を足さない。規則を書けるようにすると、規則を 1 行足すだけで + 範囲の検査を骨抜きにできてしまう。 + """ + for entry in scope: + prefix = str(entry).strip().rstrip("/") + if not prefix: + continue + if path == prefix or path.startswith(prefix + "/"): + return True + return False + + +def out_of_scope_files(commit: dict[str, Any], scope: Iterable[str]) -> list[str]: + """コミットが触った**対象範囲の外**のファイル。範囲が空なら検査しない。""" + paths = list(scope) + if not paths: + return [] + return sorted( + p for p in (commit.get("files") or []) if not path_in_scope(p, paths) + ) + + +def verify_scope(commit: dict[str, Any], scope: Iterable[str]) -> Optional[str]: + """対象範囲の外を触っていれば理由を返す。 + + 範囲を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を + 検証に反映しないと目的を果たせない。実測では、生成物を同期する規約に従った + 結果として範囲外が 3 系統変更され、差分が 4 倍に膨らんで差分予算を超えた。 + 生成物の同期が要る構成では、**同期は進行側の責務**として分離する。 + """ + outside = out_of_scope_files(commit, scope) + if not outside: + return None + shown = ", ".join(outside[:5]) + more = f" ほか {len(outside) - 5} 件" if len(outside) > 5 else "" + return ( + f"コミット {commit.get('sha', '?')} が対象範囲の外を変更しています" + f"({shown}{more})。生成物の同期は進行側が収束後にまとめて行います。" + "現状固定テストの置き場所が範囲外なら、`--scope` に含めてから実行してください" + ) + + def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: """コミットのトレーラーが 4 つ揃っているか。欠けていれば理由を返す。 @@ -377,7 +476,9 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という @@ -386,6 +487,9 @@ def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: if not commit.get("exists", True): return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -397,7 +501,8 @@ def verify_fix_commit(commit: dict[str, Any]) -> Optional[str]: def verify_apply_item( - item: dict[str, Any], facts: list[dict[str, Any]] + item: dict[str, Any], facts: list[dict[str, Any]], + scope: Optional[Iterable[str]] = None, ) -> Optional[str]: """1 項目の適用結果を検証する。問題があれば失敗理由を返す。 @@ -415,6 +520,9 @@ def verify_apply_item( "(申告だけで実体がありません)" ) problem = verify_commit_trailers(commit) + if problem: + return problem + problem = verify_scope(commit, scope or []) if problem: return problem if commit.get("test_status") != "pass": @@ -531,6 +639,56 @@ def unresolved_item_ids( # ---------------- サブコマンド ---------------- +def check_auth(runtimes: Iterable[str]) -> dict[str, dict[str, Any]]: + """参加する CLI の認証状態を確かめる。1 つでも欠けたら初期化を中断する。 + + 存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、結果ファイルを + 残さないまま提案・レビューの担当から脱落するが、**初期化は成功として扱われる** + ため、参加者が 1 人欠けた構成のまま最後まで進んでしまう。 + + 確認コマンドは CLI の版で変わりうるので、`NDF_SKIP_AUTH_CHECK` で飛ばせるように + しておく。飛ばしたことは必ず出力へ残す(黙って劣化させない)。 + """ + if os.environ.get("NDF_SKIP_AUTH_CHECK"): + info("⚠ NDF_SKIP_AUTH_CHECK が設定されているため認証確認を飛ばしました") + return {} + + results: dict[str, dict[str, Any]] = {} + failed: list[str] = [] + for runtime in runtimes: + probe = AUTH_PROBES.get(runtime) + if probe is None: + continue + env = dict(os.environ) + if runtime == "gemini": + # 新規パスは untrusted と判定されるため、確認でも信頼を明示する。 + env["GEMINI_CLI_TRUST_WORKSPACE"] = "true" + try: + r = subprocess.run(list(probe), capture_output=True, text=True, + timeout=AUTH_PROBE_TIMEOUT, env=env) + merged = f"{r.stdout}\n{r.stderr}".lower() + ok = r.returncode == 0 and not any( + m in merged for m in UNAUTHENTICATED_MARKERS + ) + detail = (r.stderr.strip() or r.stdout.strip())[:200] + except FileNotFoundError: + ok, detail = False, "コマンドが見つかりません" + except subprocess.TimeoutExpired: + ok, detail = False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" + results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} + info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") + if not ok: + failed.append(f"{runtime}({detail})") + + if failed: + die( + "認証されていない CLI があります: " + " / ".join(failed) + "。" + "参加者が欠けたまま進むと、その者の提案とレビューが無いまま収束します。" + "各 CLI でログインしてから再実行してください" + ) + return results + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -553,6 +711,10 @@ def cmd_init(args: argparse.Namespace) -> None: if host in runtimes: die(f"提案・レビューの母集合にホスト {host} が含まれています(判定の誤り)") + # **認証は作業ディレクトリを作る前に確かめる。** 未認証のまま進むと、 + # 参加者が欠けた構成のまま最後まで走り切ってしまう。 + auth = check_auth(sorted(set(runtimes) | set(impl_capable))) + repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) head_branch = _sh( ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] @@ -597,6 +759,10 @@ def cmd_init(args: argparse.Namespace) -> None: "runtimes": runtimes, "impl_capable": impl_capable, "models": model_spec, + "auth": auth, + # 提案プロンプトへ許容値をそのまま列挙するために持たせる。 + # 定義は検証側(この CLI)にあり、状態ファイル経由で起動側へ渡す。 + "vocabulary": vocabulary(), "skills": {"required": list(REQUIRED_SKILLS)}, "max_outer_rounds": args.max_outer_rounds, "max_fix_rounds": args.max_fix_rounds, @@ -816,7 +982,10 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: - result = _result_path(state, runtime, stem_for(runtime, "propose", state["id"])) + result = _result_path( + state, runtime, + stem_for(runtime, "propose", state["id"], entry["round"]), + ) if not result.exists(): info(f"⚠ {runtime} の提案結果がありません: {result}") continue @@ -1043,6 +1212,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "item_id": f"R{entry['round']}-range", "commits": list(ordered_range), } + if not args.dry_run: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) _revert_item_commits(state, whole_round, args.dry_run) if not args.dry_run: # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 @@ -1060,12 +1234,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: if args.dry_run: info("(dry-run)状態ファイルは更新していません") else: - _push_with_retry_marker(path, state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) sys.exit(2) applied: list[str] = [] failed: list[str] = [] - reverted = 0 + scope = state.get("target_scope") or [] + # **判定はその都度残す。** まとめて最後に保存すると、取り消しの途中で中断した + # ときに適用の記録が一切残らず、どのコミットが検証を通ったのかを状態から + # 復元できなくなる。再開可能性は収束ループの前提なので、ここが崩れると + # 中断からの復帰手段が無くなる。 + progress: list[dict[str, Any]] = [] + entry["apply_progress"] = progress for item_id in entry["items"]: item = _find_item(state, item_id) got = reported.get(item_id) @@ -1077,25 +1260,31 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: work, _reported_shas(got), in_range, test_command, head_branch, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - problem = verify_apply_item(item, facts) + problem = verify_apply_item(item, facts, scope) if problem: item["status"] = "abandoned" item["failure_reason"] = problem item["test_failed"] = bool(got and "テストが成功していません" in problem) item["budget_exceeded"] = bool(got and "差分予算" in problem) - # **検証に失敗した項目のコミットを Pull Request に残さない。** - # 実装担当は項目ごとに push しているため、状態を `abandoned` にする - # だけでは差分が残り、以後のレビュー対象にも混入する。 + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 item["commits"] = _reported_shas(got) - reverted += _revert_item_commits(state, item, args.dry_run) failed.append(item_id) info(f"❌ {item_id}: {problem}") - continue - item["status"] = "reviewing" - item["commits"] = _reported_shas(got) - item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) - applied.append(item_id) - info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + else: + item["status"] = "reviewing" + item["commits"] = _reported_shas(got) + item["diff_lines"] = sum(_safe_int(c.get("diff_lines")) for c in facts) + applied.append(item_id) + info(f"✅ {item_id}: {len(item['commits'])} コミット / {item['diff_lines']} 行") + progress.append({ + "item_id": item_id, "at": statefile.now(), + "result": "failed" if problem else "ok", + "reason": problem, "commits": list(item.get("commits") or []), + }) + if not args.dry_run: + statefile.save(path, state) entry["apply"] = { "applied": applied, @@ -1114,12 +1303,39 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の # つもりで実行した利用者の進行が壊れる。 if args.dry_run: + if failed: + _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") + elif failed: + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、検証を通っていない変更が Pull Request に残り、次の実行は + # 処理済みガードで素通りしてしまう。 + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, failed) + if result["mode"] == "round": + # 積み直せなかった。合意済みの項目も含めて全件捨てる。 + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it.setdefault( + "failure_reason", + "残す項目を積み直せなかったため、ラウンドごと取り消した", + ) + applied, failed = [], list(entry["items"]) + entry["apply"]["applied"] = applied + entry["apply"]["failed"] = failed + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + # 保存してから push する。push が失敗しても、記録とローカルの git が + # 食い違わない。 + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) else: - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) - if reverted: - _push_with_retry_marker(path, state, entry) if not applied: info("全項目が失敗したため、このラウンドのレビューは行いません") @@ -1291,27 +1507,42 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) return + if args.dry_run: + _drop_items(state, entry, targets, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + return + + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、Pull Request 側には未検証の差分が残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, targets) + if result["mode"] == "round": + info("積み直せなかったため、このラウンドで適用した項目を全件見送ります") + targets = list(entry["apply"].get("applied") or targets) + + already = {d.get("item_id") for d in state["deferred_items"]} for item_id in targets: item = _find_item(state, item_id) - count = _revert_item_commits(state, item, args.dry_run) item["status"] = "abandoned" item.setdefault("failure_reason", "修正ラウンドの上限に達しても指摘が解決しなかった") + if item_id in already: + continue state["deferred_items"].append({ "item_id": item_id, "path": item["path"], "symbol": item["symbol"], "smell": item["smell"], "round": entry["round"], "defer_reason": item["failure_reason"], }) - info(f"↩ {item_id} を取り消しました({count} コミット)") + info(f"↩ {item_id} を見送りました") entry["abandoned"] = targets state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - return # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) - _push_with_retry_marker(path, state, entry) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) def cmd_merge_fix(args: argparse.Namespace) -> None: @@ -1416,7 +1647,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: needs_push = False for commit in facts: item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit) + problem = verify_fix_commit(commit, state.get("target_scope") or []) if problem: problems.append(problem) info(f"❌ 修正コミットが手順を満たしていません: {problem}") @@ -1433,6 +1664,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") + # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに + # 終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) _revert_item_commits( state, {"item_id": f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", @@ -1706,6 +1941,12 @@ def commit_diff_lines(work: str, sha: str) -> int: return total +def commit_files(work: str, sha: str) -> list[str]: + """コミットが触ったファイルのリポジトリ相対パス。範囲の検査に使う。""" + out = _git_out(work, ["show", "--name-only", "--format=", sha]) + return [p.strip() for p in (out or "").splitlines() if p.strip()] + + def commit_touches_tests(work: str, sha: str) -> bool: """コミットがテストの置き場所を触っているか。""" out = _git_out(work, ["show", "--name-only", "--format=", sha]) @@ -1843,6 +2084,7 @@ def collect_commit_facts( "exists": True, "trailers": commit_trailers(work, full), "diff_lines": commit_diff_lines(work, full), + "files": commit_files(work, full), "touches_tests": commit_touches_tests(work, full), "test_status": run_test_at( work, full, test_command, head_branch, test_timeout @@ -1958,6 +2200,171 @@ def _revert_item_commits( return len(shas) +def _reset_hard(work: str, sha: Optional[str]) -> None: + """着手前の HEAD へ戻す。半端な履歴を Pull Request に残さないための後始末。""" + if sha: + subprocess.run(["git", "reset", "--hard", sha], cwd=work, + capture_output=True, text=True) + + +def _revert_range(work: str, ordered: list[str], before: Optional[str]) -> None: + """範囲を**新しい順に**全て取り消す。失敗したら着手前へ戻して中断する。 + + 範囲全体を新しい順にたどる取り消しは、履歴をそのまま逆再生するだけなので + **競合しない**。競合するのは「一部のコミットだけを飛ばして戻す」ときである。 + """ + for sha in ordered: + r = subprocess.run( + ["git", "revert", "--no-edit", sha], + cwd=work, capture_output=True, text=True, + ) + if r.returncode != 0: + subprocess.run(["git", "revert", "--abort"], cwd=work, + capture_output=True, text=True) + _reset_hard(work, before) + die( + f"コミット {sha} を取り消せませんでした: {r.stderr.strip()[:400]}" + f"(HEAD を {before} へ戻しました)" + ) + + +def _replay_commits(work: str, shas: list[str]) -> Optional[dict[str, str]]: + """残す項目のコミットを**古い順に**積み直し、`{元の SHA: 新しい SHA}` を返す。 + + 競合したら `None` を返す。**ここで中断しない。** どの項目を残せるか決められない + だけなので、呼び出し側がラウンド全件の取り消しへ退避できる。 + """ + mapping: dict[str, str] = {} + for sha in shas: + r = subprocess.run( + ["git", "cherry-pick", "--allow-empty", sha], + cwd=work, capture_output=True, text=True, + ) + if r.returncode != 0: + subprocess.run(["git", "cherry-pick", "--abort"], cwd=work, + capture_output=True, text=True) + info(f"⚠ {sha[:7]} を積み直せませんでした: {r.stderr.strip()[:200]}") + return None + mapping[sha] = _git_out(work, ["rev-parse", "HEAD"]) or sha + return mapping + + +def _commit_owner( + work: str, state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, str]: + """このラウンドの `コミット → 改善項目 ID` の対応。完全な SHA へ正規化する。 + + どの項目にも属さないコミット(過去の取り消しなど)はここに現れない。 + 積み直しの対象から外すために、**属さないこと**を判定できる形にしておく。 + """ + owner: dict[str, str] = {} + for item_id in entry["items"]: + item = _find_item(state, item_id, required=False) + if item is None: + continue + for sha in item.get("commits") or []: + if not isinstance(sha, str) or not sha.strip(): + continue + full = _git_out(work, ["rev-parse", "--verify", f"{sha.strip()}^{{commit}}"]) + owner[full or sha.strip()] = item_id + return owner + + +def _drop_items( + state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], + dry_run: bool = False, +) -> dict[str, Any]: + """改善項目を取り消し、残す項目を積み直す。 + + **範囲を新しい順に全て戻してから、残す項目を古い順に積み直す。** 項目のコミット + だけを戻すと、取り消し対象より新しい**別項目**のコミットが同じ箇所を触っている + ときに必ず競合する(実測では採用 5 件のうち 4 件が同一ファイルの隣接領域を + 変更しており、取り消しが競合して進行が止まった)。 + + 積み直しが競合したときは着手前 HEAD へ戻し、**ラウンド全件の取り消しへ退避する**。 + どの項目を残せるか決められない以上、半端な履歴を残すより全件捨てる方が安全である。 + + 戻り値の `mode` は次の 3 つ。 + + | 値 | 意味 | + | --- | --- | + | `item` | 項目単位で取り消し、残す項目を積み直した | + | `round` | 積み直せず、ラウンド全件を取り消した(退避) | + | `skip` | 取り消すものが無かった(取り消し済み) | + """ + work = state["worktrees"]["work"] + pending = [ + i for i in drop_ids + if not (_find_item(state, i, required=False) or {}).get("reverted") + ] + if not pending: + info("↩ 取り消し対象は取り消し済みです") + return {"mode": "skip", "dropped": [], "reverted": 0, "replayed": 0} + + head = _git_out(work, ["rev-parse", "HEAD"]) + ordered = commits_in_range(work, entry.get("apply_base_sha"), head or "HEAD") + if ordered is None: + # 起点を記録していない状態ファイル(旧版)では積み直せない。 + # 従来どおり項目のコミットだけを新しい順に戻す。 + info("⚠ 適用の範囲を確定できないため、項目のコミットだけを取り消します") + reverted = 0 + for item_id in pending: + reverted += _revert_item_commits(state, _find_item(state, item_id), dry_run) + return {"mode": "item", "dropped": pending, + "reverted": reverted, "replayed": 0} + + owner = _commit_owner(work, state, entry) + drop = set(pending) + keep_ids = [ + i for i in entry["items"] + if i not in drop + and not (_find_item(state, i, required=False) or {}).get("reverted") + ] + # `ordered` は新しい順なので、積み直しは反転して古い順にする。 + # **どの項目にも属さないコミット(過去の取り消しなど)は積み直さない。** + replay = [s for s in reversed(ordered) if owner.get(s) in keep_ids] + + if dry_run: + for sha in ordered: + info(f"(dry-run)git revert --no-edit {sha}") + for sha in replay: + info(f"(dry-run)git cherry-pick {sha}") + return {"mode": "item", "dropped": pending, + "reverted": len(ordered), "replayed": len(replay)} + + _revert_range(work, ordered, head) + mapping = _replay_commits(work, replay) + mode = "item" + if mapping is None: + info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") + _reset_hard(work, head) + _revert_range(work, ordered, head) + mapping, mode = {}, "round" + + dropped = list(entry["items"]) if mode == "round" else pending + for item_id in entry["items"]: + item = _find_item(state, item_id, required=False) + if item is None: + continue + if mode == "round" or item_id not in keep_ids: + item["reverted"] = True + continue + # **積み直しで SHA が変わる。** 記録を更新しないと、次の取り消しが + # 履歴に無い SHA を指してしまう。 + item["commits"] = [mapping[s] for s in replay if owner.get(s) == item_id] + + entry.setdefault("drops", []).append({ + "at": statefile.now(), "mode": mode, "dropped": dropped, + "reverted": len(ordered), "replayed": len(mapping), + }) + info( + f"↩ 取り消し {len(ordered)} コミット / 積み直し {len(mapping)} コミット" + f"({'ラウンド全件へ退避' if mode == 'round' else '項目単位'})" + ) + return {"mode": mode, "dropped": dropped, + "reverted": len(ordered), "replayed": len(mapping)} + + def _order_newest_first(work: str, shas: list[str]) -> list[str]: """コミットを **git の履歴順(新しい順)** に並べ替える。 diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py index b6828948..e5c46d59 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py @@ -767,3 +767,106 @@ def failing_sh(cmd, **kw): assert [c for c in pushes if c[:2] == ["git", "push"]], "再試行していない" assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +# ---------- 巻き戻して積み直す取り消し ---------- + +def _range_state(tmp_path, findings, item_ids=("R1-001", "R1-002")): + """適用の起点を記録した状態。**積み直しの経路**を通る。""" + import json as _json + state_path = _state(tmp_path, findings, item_ids=item_ids) + state = read_state(state_path) + state["rounds"][0]["apply_base_sha"] = "BASE" + state_path.write_text(_json.dumps(state, ensure_ascii=False), encoding="utf-8") + return state_path + + +def _range_env(refactor, monkeypatch, ordered, pick_rc=0): + """範囲と git 操作を差し替える。`ordered` は新しい順。""" + calls: list[list[str]] = [] + picked: list[str] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + rc = 0 + if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: + rc = pick_rc + if rc == 0: + picked.append(cmd[-1]) + return subprocess.CompletedProcess(cmd, rc, "", "conflict" if rc else "") + + def fake_git_out(work, args): + if args[:2] == ["rev-parse", "--verify"]: + return args[-1].replace("^{commit}", "") + if args == ["rev-parse", "HEAD"]: + return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + return "HEAD_BEFORE" + + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + monkeypatch.setattr(refactor, "_git_out", fake_git_out) + monkeypatch.setattr(refactor, "commits_in_range", + lambda work, base, head: list(ordered)) + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: "") + return calls + + +def test_abandon_replays_the_items_that_stay( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """見送る項目より新しいコミットがあっても競合しないこと。 + + 範囲を新しい順に全て戻してから、残す項目を古い順に積み直す。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + # 履歴は R1-002 のコミットが新しい + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + + refactor.cmd_abandon_items(_args()) + + assert [c[-1] for c in calls if c[:2] == ["git", "revert"]] == [ + "sha-R1-002", "sha-R1-001"] + assert [c[-1] for c in calls if c[:2] == ["git", "cherry-pick"]] == ["sha-R1-002"] + + state = read_state(state_path) + by_id = {i["item_id"]: i for i in state["items"]} + assert by_id["R1-001"]["status"] == "abandoned" + assert by_id["R1-002"]["status"] == "reviewing" + assert by_id["R1-002"]["commits"] == ["new-sha-R1-002"] + + +def test_abandon_falls_back_to_the_whole_round_on_a_replay_conflict( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"], pick_rc=1) + + refactor.cmd_abandon_items(_args()) + + assert ["git", "cherry-pick", "--abort"] in calls + state = read_state(state_path) + assert all(i["status"] == "abandoned" for i in state["items"]) + assert sorted(d["item_id"] for d in state["deferred_items"]) == ["R1-001", "R1-002"] + assert state["rounds"][0]["drops"][-1]["mode"] == "round" + + +def test_abandon_marks_pending_push_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + marks: list[bool] = [] + _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + marks.append(read_state(state_path)["rounds"][0].get("pending_push")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + refactor.cmd_abandon_items(_args()) + + assert marks and marks[0] is True + assert read_state(state_path)["rounds"][0]["pending_push"] is False diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_drop_items_git.py new file mode 100644 index 00000000..bf828a03 --- /dev/null +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_drop_items_git.py @@ -0,0 +1,221 @@ +"""取り消しと積み直しを**実際の git** で確かめる。 + +差し替えたコマンド列だけを見ても「競合しないか」は示せない。ここでは本物の +リポジトリを作り、2 つの改善項目の位置関係を変えて挙動を確かめる。 + +| 位置関係 | 結果 | +| --- | --- | +| 別ファイル / 離れた行 | 項目単位で取り消し、残す項目は積み直せる | +| 同一ファイルの隣接行 | 積み直せないのでラウンド全件へ退避する | + +**隣接する変更は git だけでは分離できない。** 取り消した側の行が消えると、残す側の +パッチが前提にしている文脈も消えるためである。退避してでも Pull Request を +決定的な状態に保つことを優先する。 +""" +from __future__ import annotations + +import shutil +import subprocess + +import pytest + +pytestmark = pytest.mark.skipif(shutil.which("git") is None, reason="git が必要") + +LINES = [f"line{i}\n" for i in range(1, 41)] + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, + text=True, check=True) + + +def _commit(repo, message): + _git("add", "-A", cwd=repo) + _git("-c", "user.email=t@e.st", "-c", "user.name=test", + "commit", "-qm", message, cwd=repo) + return _git("rev-parse", "HEAD", cwd=repo).stdout.strip() + + +def _make_repo(tmp_path, second_change): + """`R1-001` が 3 行目を、`R1-002` が `second_change` で示す箇所を変える。""" + repo = tmp_path / "repo" + (repo / "src").mkdir(parents=True) + _git("init", "-q", str(repo), cwd=tmp_path) + (repo / "src" / "foo.py").write_text("".join(LINES), encoding="utf-8") + (repo / "src" / "bar.py").write_text("".join(LINES), encoding="utf-8") + base = _commit(repo, "init") + + lines = list(LINES) + lines[2] = "line3-by-R1-001\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + c1 = _commit(repo, "R1-001") + + second_change(repo, lines) + c2 = _commit(repo, "R1-002") + return {"repo": repo, "base": base, "c1": c1, "c2": c2} + + +def _touch_adjacent_line(repo, lines): + lines = list(lines) + lines[3] = "line4-by-R1-002\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + + +def _touch_distant_line(repo, lines): + lines = list(lines) + lines[30] = "line31-by-R1-002\n" + (repo / "src" / "foo.py").write_text("".join(lines), encoding="utf-8") + + +def _touch_other_file(repo, lines): + other = list(LINES) + other[2] = "line3-by-R1-002\n" + (repo / "src" / "bar.py").write_text("".join(other), encoding="utf-8") + + +@pytest.fixture +def adjacent_repo(tmp_path): + """同一ファイルの**隣接行**を触る 2 項目。実機で進行が止まった位置関係。""" + return _make_repo(tmp_path, _touch_adjacent_line) + + +@pytest.fixture +def distant_repo(tmp_path): + """同一ファイルの**離れた行**を触る 2 項目。""" + return _make_repo(tmp_path, _touch_distant_line) + + +@pytest.fixture +def separate_repo(tmp_path): + """**別ファイル**を触る 2 項目。""" + return _make_repo(tmp_path, _touch_other_file) + + +def _state(built): + entry = { + "round": 1, + "items": ["R1-001", "R1-002"], + "apply_base_sha": built["base"], + "apply": {"applied": ["R1-001", "R1-002"], "failed": []}, + } + state = { + "worktrees": {"work": str(built["repo"])}, + "rounds": [entry], + "items": [ + {"item_id": "R1-001", "round": 1, "status": "reviewing", + "commits": [built["c1"]]}, + {"item_id": "R1-002", "round": 1, "status": "reviewing", + "commits": [built["c2"]]}, + ], + } + return state, entry + + +def _content(built, name="foo.py"): + return (built["repo"] / "src" / name).read_text(encoding="utf-8") + + +# ---------- 前提の確認 ---------- + +def test_reverting_only_the_older_commit_conflicts(adjacent_repo): + """古い方だけを戻すと本当に競合すること。 + + これが競合しないなら、取り消しの作り直しそのものが不要になる。 + """ + r = subprocess.run( + ["git", "revert", "--no-edit", adjacent_repo["c1"]], + cwd=adjacent_repo["repo"], capture_output=True, text=True, + ) + assert r.returncode != 0, "競合しない位置関係になっている(テストの前提が崩れた)" + subprocess.run(["git", "revert", "--abort"], cwd=adjacent_repo["repo"], + capture_output=True, text=True) + + +# ---------- 項目単位で取り消せる場合 ---------- + +@pytest.mark.parametrize("fixture_name", ["distant_repo", "separate_repo"]) +def test_drop_older_item_keeps_the_newer_one(refactor, request, fixture_name): + """独立した変更なら、古い項目だけを取り消して新しい項目を残せること。""" + built = request.getfixturevalue(fixture_name) + state, entry = _state(built) + result = refactor._drop_items(state, entry, ["R1-001"]) + + assert result["mode"] == "item" + assert "line3-by-R1-001" not in _content(built), "取り消した項目の変更が残っている" + assert "R1-002" in _content(built) + _content(built, "bar.py"), \ + "残すはずの項目の変更が消えている" + + by_id = {i["item_id"]: i for i in state["items"]} + assert by_id["R1-001"]["reverted"] is True + # 積み直しで SHA が変わるので、記録も追従していること + head = _git("rev-parse", "HEAD", cwd=built["repo"]).stdout.strip() + assert by_id["R1-002"]["commits"] == [head] + + +def test_drop_newer_item_keeps_the_older_one(refactor, distant_repo): + """新しい項目だけを取り消す向きでも成立すること。""" + state, entry = _state(distant_repo) + assert refactor._drop_items(state, entry, ["R1-002"])["mode"] == "item" + assert "line3-by-R1-001" in _content(distant_repo) + assert "line31-by-R1-002" not in _content(distant_repo) + + +def test_second_drop_after_the_first_still_works(refactor, distant_repo): + """1 回目で積み直した SHA に対して、もう一度取り消せること。 + + 積み直しで SHA が変わるので、記録を更新していないとここで破綻する。 + """ + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001"]) + assert refactor._drop_items(state, entry, ["R1-002"])["mode"] == "item" + assert _content(distant_repo) == "".join(LINES) + + +def test_dropping_is_idempotent(refactor, distant_repo): + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001"]) + head = _git("rev-parse", "HEAD", cwd=distant_repo["repo"]).stdout.strip() + + assert refactor._drop_items(state, entry, ["R1-001"])["mode"] == "skip" + assert _git("rev-parse", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() == head + + +# ---------- 積み直せない場合 ---------- + +def test_adjacent_changes_fall_back_to_the_whole_round(refactor, adjacent_repo): + """隣接する変更は分離できない。退避して全件取り消すこと。 + + 半端な履歴を残すより、決定的な状態へ落とす方が安全である。 + """ + state, entry = _state(adjacent_repo) + result = refactor._drop_items(state, entry, ["R1-001"]) + + assert result["mode"] == "round" + assert _content(adjacent_repo) == "".join(LINES), "着手前の内容へ戻っていない" + assert all(i["reverted"] for i in state["items"]) + assert entry["drops"][-1]["mode"] == "round" + + +def test_dropping_every_item_returns_to_the_base_tree(refactor, distant_repo): + state, entry = _state(distant_repo) + refactor._drop_items(state, entry, ["R1-001", "R1-002"]) + assert _content(distant_repo) == "".join(LINES) + + +def test_history_is_never_rewritten(refactor, distant_repo): + """`--force` を使わずに済むよう、前進だけで戻すこと。""" + state, entry = _state(distant_repo) + before = _git("rev-list", "--count", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() + refactor._drop_items(state, entry, ["R1-001"]) + after = _git("rev-list", "--count", "HEAD", + cwd=distant_repo["repo"]).stdout.strip() + assert int(after) > int(before), "履歴を書き換えている" + assert built_commits_still_reachable(distant_repo) + + +def built_commits_still_reachable(built) -> bool: + """着手前のコミットが履歴から消えていないこと。""" + log = _git("rev-list", "HEAD", cwd=built["repo"]).stdout.split() + return built["c1"] in log and built["base"] in log diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_init.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_init.py index 9e566545..4987b7d6 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_init.py @@ -86,6 +86,9 @@ def fake_sh(cmd, cwd=None, check=True): monkeypatch.setattr(refactor, "_sh", fake_sh) monkeypatch.chdir(origin_repo) monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) + # 認証確認は実際の CLI を起動する。ここでは対象外なので飛ばす + # (確認そのものは `test_init_checks_cli_authentication` で見る)。 + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") refactor.cmd_init(args) return _run @@ -234,3 +237,94 @@ def test_diverged_worktree_stops_the_run(run_init, tmp_path): with pytest.raises(SystemExit): run_init(_args(tmp_path)) + + +# ---------- 語彙と認証 ---------- + +def test_init_records_the_vocabulary_for_the_prompt(run_init, tmp_path, refactor): + """許容値をプロンプトへ列挙できるよう、語彙集合を状態へ残すこと。 + + 手順書の見出しは日本語なので、「語彙に限定する」とだけ書くと読んだ側が + 日本語を語彙と解釈する(実測で gemini の提案 4 件が全件見送りになった)。 + """ + run_init(_args(tmp_path)) + _, state = _state_of(tmp_path) + assert state["vocabulary"]["smells"]["long_method"] == "長すぎるメソッド" + assert "extract_method" in state["vocabulary"]["techniques"] + assert state["vocabulary"]["severities"] == ["minor", "major", "critical"] + # 定義は検証側の 1 箇所だけに置く + assert state["vocabulary"]["smells"] == refactor.SMELLS + + +def _probe_result(refactor, monkeypatch, outcomes): + """認証確認コマンドの結果を差し替える。`{ランタイム: (rc, 出力)}`。""" + def fake_run(cmd, **kwargs): + for runtime, probe in refactor.AUTH_PROBES.items(): + if list(cmd) == list(probe): + rc, out = outcomes.get(runtime, (0, "ok")) + return subprocess.CompletedProcess(cmd, rc, out, "") + raise AssertionError(f"想定外の呼び出し: {cmd}") + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + + +def test_check_auth_passes_when_every_cli_is_logged_in(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {}) + results = refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + assert all(r["ok"] for r in results.values()) + + +def test_check_auth_fails_on_a_non_zero_exit(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {"kiro": (1, "")}) + with pytest.raises(SystemExit) as e: + refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + assert e.value.code == refactor.ABORT + + +def test_check_auth_fails_when_the_output_says_not_logged_in(refactor, monkeypatch): + """終了コード 0 でも未認証を示すことがある(kiro は成否を終了コードで表さない)。""" + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + _probe_result(refactor, monkeypatch, {"kiro": (0, "Not logged in")}) + with pytest.raises(SystemExit): + refactor.check_auth(["claude", "codex", "gemini", "kiro"]) + + +def test_check_auth_fails_when_the_cli_is_missing(refactor, monkeypatch): + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + + def missing(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(refactor.subprocess, "run", missing) + with pytest.raises(SystemExit): + refactor.check_auth(["codex"]) + + +def test_check_auth_can_be_skipped_explicitly(refactor, monkeypatch): + """確認コマンドは CLI の版で変わる。飛ばせる逃げ道を残す。""" + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") + + def never(cmd, **kwargs): + raise AssertionError("認証確認を実行してはいけない") + + monkeypatch.setattr(refactor.subprocess, "run", never) + assert refactor.check_auth(["codex", "gemini"]) == {} + + +def test_init_checks_cli_authentication(refactor, origin_repo, monkeypatch, tmp_path): + """未認証の CLI があれば初期化ごと中断すること。 + + 参加者が 1 人欠けた構成のまま進むと、その者の提案とレビューが無いまま収束する。 + """ + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + monkeypatch.chdir(origin_repo) + monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) + _probe_result(refactor, monkeypatch, {"gemini": (1, "Authentication failed")}) + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: pytest.fail("認証確認より前に gh を呼んでいる"), + ) + with pytest.raises(SystemExit) as e: + refactor.cmd_init(_args(tmp_path)) + assert e.value.code == refactor.ABORT diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py index ccb44961..3074e0fa 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py @@ -383,24 +383,56 @@ def test_self_reported_values_cannot_pass_the_check( assert "テストが成功していません" in state["items"][0]["failure_reason"] -def test_failed_item_commits_are_reverted( - refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts -): - """検証に失敗した項目のコミットを Pull Request に残さない。 +def _drop_env(refactor, monkeypatch, revert_rc=0, pick_rc=0): + """取り消しと積み直しを実際には走らせず、順序と引数を記録する。 - 実装担当は項目ごとに push しているため、状態を `abandoned` にするだけでは - 差分が残り、以後のレビュー対象にも混入する。 + `git rev-parse HEAD` は**直前に積み直したコミット**に応じた値を返す。 + 積み直しで SHA が変わることを、状態の更新まで含めて確かめられるようにする。 """ + calls: list[list[str]] = [] + picked: list[str] = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + rc = 0 + if cmd[:2] == ["git", "revert"] and "--abort" not in cmd: + rc = revert_rc + if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: + rc = pick_rc + if rc == 0: + picked.append(cmd[-1]) + return subprocess.CompletedProcess(cmd, rc, "", "conflict" if rc else "") + + def fake_git_out(work, args): + if args[:2] == ["rev-parse", "--verify"]: + return args[-1].replace("^{commit}", "") + if args == ["rev-parse", "HEAD"]: + return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + return "HEAD_BEFORE" + + monkeypatch.setattr(refactor.subprocess, "run", fake_run) + monkeypatch.setattr(refactor, "_git_out", fake_git_out) + pushes: list[list[str]] = [] + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: pushes.append(list(cmd)) or "") + return calls, pushes + + +def _two_item_apply(tmp_path, env_tmp_dir, git_facts): + """1 件成功・1 件失敗の適用結果を用意する。失敗するのは R1-002。""" items = [item(item_id="R1-001"), item(item_id="R1-002")] state_path = _state_with_items(tmp_path, items) env_tmp_dir(state_path) - git_facts({ - "ok111": fact(sha="ok111"), - "bad111": fact(sha="bad111", diff_lines=400, - trailers=trailers(item_id="R1-002")), - "bad222": fact(sha="bad222", diff_lines=400, - trailers=trailers(item_id="R1-002")), - }) + git_facts( + { + "ok111": fact(sha="ok111"), + "bad111": fact(sha="bad111", diff_lines=400, + trailers=trailers(item_id="R1-002")), + "bad222": fact(sha="bad222", diff_lines=400, + trailers=trailers(item_id="R1-002")), + }, + # 履歴は bad222 が最も新しい + in_range=["bad222", "bad111", "ok111"], + ) write_result(state_path, "codex-apply-r1", { "base_sha": "aaa", "items": [ @@ -409,36 +441,181 @@ def test_failed_item_commits_are_reverted( {"item_id": "R1-002", "commits": [{"sha": "bad111"}, {"sha": "bad222"}]}, ], }) + return state_path - calls: list[list[str]] = [] - monkeypatch.setattr( - refactor.subprocess, "run", - lambda cmd, **kw: calls.append(list(cmd)) - or subprocess.CompletedProcess(cmd, 0, "", ""), - ) - pushes: list[list[str]] = [] - monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: pushes.append(cmd) or "") - # git の履歴は bad222 が最も新しい - monkeypatch.setattr( - refactor, "_git_out", - lambda work, args: ("bad222\nbad111\nok111" if args[:1] == ["rev-list"] - else args[-1].replace("^{commit}", "")), - ) + +def test_dropping_an_item_replays_the_kept_items( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """範囲を新しい順に全て戻し、残す項目を古い順に積み直すこと。 + + 失敗した項目のコミット**だけ**を戻すと、あとから同じ箇所を触った別項目の + コミットと必ず競合する。範囲全体の巻き戻しは履歴の逆再生なので競合しない。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, pushes = _drop_env(refactor, monkeypatch) refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) - reverts = [c for c in calls if c[:2] == ["git", "revert"]] - # 新しいコミットから順に戻す - assert [c[-1] for c in reverts] == ["bad222", "bad111"] + reverts = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + picks = [c[-1] for c in calls if c[:2] == ["git", "cherry-pick"]] + assert reverts == ["bad222", "bad111", "ok111"], "範囲を新しい順に全て戻していない" + assert picks == ["ok111"], "残す項目だけを積み直していない" assert pushes, "取り消し後に push していない" for cmd in pushes: - assert "--force" not in cmd + assert "--force" not in cmd and "--no-verify" not in cmd state = read_state(state_path) by_id = {i["item_id"]: i for i in state["items"]} assert by_id["R1-001"]["status"] == "reviewing" assert by_id["R1-002"]["status"] == "abandoned" - assert by_id["R1-002"]["commits"] == ["bad111", "bad222"] + assert by_id["R1-002"]["reverted"] is True + # 積み直しで SHA が変わるので、記録も追従すること + assert by_id["R1-001"]["commits"] == ["new-ok111"] + + +def test_replay_conflict_falls_back_to_whole_round( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """積み直せないときはラウンド全件の取り消しへ退避すること。 + + どの項目を残せるか決められない以上、半端な履歴を残すより全件捨てる方が安全。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, _ = _drop_env(refactor, monkeypatch, pick_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == 2, "全件失敗として次の提案ラウンドへ進むこと" + + assert ["git", "cherry-pick", "--abort"] in calls + assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + state = read_state(state_path) + assert all(i["status"] == "abandoned" for i in state["items"]) + assert state["rounds"][0]["apply"]["applied"] == [] + assert state["rounds"][0]["drops"][-1]["mode"] == "round" + + +def test_revert_failure_aborts_with_the_abort_code( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しに失敗したら「全件失敗」ではなく**中断**として終わること。 + + 2(全件失敗)と同じ扱いにすると、検証を通っていない変更を Pull Request に + 残したまま次の提案ラウンドが始まる。 + """ + _two_item_apply(tmp_path, env_tmp_dir, git_facts) + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == refactor.ABORT == 4 + assert ["git", "revert", "--abort"] in calls + assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + + +def test_progress_is_recorded_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しの前に判定を残すこと。中断しても到達点が状態から読める。""" + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + seen: list[list[dict]] = [] + + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + seen.append(read_state(state_path)["rounds"][0].get("apply_progress")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + assert seen, "取り消しが走っていない" + recorded = {p["item_id"]: p["result"] for p in seen[0]} + assert recorded == {"R1-001": "ok", "R1-002": "failed"} + + +def test_pending_push_is_marked_before_reverting( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しへ着手する前に再送信の印を立てること。 + + 取り消しは済んだのに push できずに終わると、検証を通っていない変更が + Pull Request に残り、次の実行は処理済みガードで素通りする。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + marks: list[bool] = [] + + calls, _ = _drop_env(refactor, monkeypatch, revert_rc=1) + real_run = refactor.subprocess.run + + def spying_run(cmd, **kwargs): + if cmd[:2] == ["git", "revert"]: + marks.append(read_state(state_path)["rounds"][0].get("pending_push")) + return real_run(cmd, **kwargs) + + monkeypatch.setattr(refactor.subprocess, "run", spying_run) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert marks and marks[0] is True + + +def test_pending_push_is_cleared_after_a_successful_push( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +def test_out_of_scope_commit_fails_the_item( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """指定した範囲の外を触ったコミットを検証で捕まえること。 + + 範囲を必須にした目的(提案の発散と変更の肥大を防ぐ)を、検証へ反映する。 + """ + items = [item(item_id="R1-001")] + state_path = _state_with_items(tmp_path, items) + env_tmp_dir(state_path) + git_facts({"out111": fact( + sha="out111", files=["src/foo.py", "dist/foo.py"], + )}) + write_result(state_path, "codex-apply-r1", { + "base_sha": "aaa", + "items": [{"item_id": "R1-001", "commits": [{"sha": "out111"}]}], + }) + _drop_env(refactor, monkeypatch) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == 2 + state = read_state(state_path) + assert state["items"][0]["out_of_scope"] is True + assert "dist/foo.py" in state["items"][0]["failure_reason"] + + +def test_scope_check_matches_only_on_path_prefix(refactor): + assert refactor.path_in_scope("src/foo.py", ["src"]) + assert refactor.path_in_scope("src", ["src"]) + assert not refactor.path_in_scope("src2/foo.py", ["src"]), "前方一致の取りこぼし" + assert not refactor.path_in_scope("dist/foo.py", ["src"]) + # 範囲が空なら検査しない(指定が無いのに全件落とさない) + assert refactor.out_of_scope_files({"files": ["any.py"]}, []) == [] def test_no_push_when_nothing_was_reverted( diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_proposals.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_proposals.py index 6e5ea1a3..e9882455 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_proposals.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_proposals.py @@ -185,9 +185,9 @@ def test_merge_proposals_command_creates_items( "durations": {}, "reviews": [], }]) env_tmp_dir(state_path) - write_result(state_path, "codex-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "gemini-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "kiro-propose-rf130", {"items": []}) + write_result(state_path, "codex-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "gemini-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": []}) refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -210,7 +210,7 @@ def test_merge_proposals_command_exits_2_when_nothing_adopted( }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": []}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": []}) with pytest.raises(SystemExit) as e: refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -234,9 +234,9 @@ def test_non_object_proposal_result_is_treated_as_empty( "durations": {}, "reviews": [], }]) env_tmp_dir(state_path) - write_result(state_path, "codex-propose-rf130", ["配列で返ってきた"]) - write_result(state_path, "gemini-propose-rf130", {"items": [proposal()]}) - write_result(state_path, "kiro-propose-rf130", {"items": [proposal()]}) + write_result(state_path, "codex-propose-rf130-r1", ["配列で返ってきた"]) + write_result(state_path, "gemini-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": [proposal()]}) refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) @@ -259,7 +259,7 @@ def test_merge_proposals_is_idempotent(refactor, tmp_path, env_tmp_dir, no_git): }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": [proposal()]}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": [proposal()]}) args = type("A", (), {"id": 130})() refactor.cmd_merge_proposals(args) @@ -285,7 +285,7 @@ def test_merge_proposals_replays_the_converged_exit_code( }]) env_tmp_dir(state_path) for rt in ("codex", "gemini", "kiro"): - write_result(state_path, f"{rt}-propose-rf130", {"items": []}) + write_result(state_path, f"{rt}-propose-rf130-r1", {"items": []}) args = type("A", (), {"id": 130})() for _ in range(2): diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_prepare_worktrees.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_prepare_worktrees.py index 08da12aa..14973eaa 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_prepare_worktrees.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_prepare_worktrees.py @@ -250,3 +250,35 @@ def test_empty_destination_is_provisioned(repo): dest.mkdir() _run(repo) assert (dest / "SKILL.md").is_file() + + +# ---------- gemini の読み取り除外 ---------- + +def test_gemini_gets_a_setting_that_allows_reading_the_provisioned_skills(repo): + """gemini は除外設定を**読み取りにも**適用するため、無効にする設定を置く。 + + 置かないと、配置した手順書を `read_file` で一切開けず、 + 語彙を読めないまま提案が語彙外になって全件降格する。 + """ + _run(repo) + settings = repo["root"] / "gemini" / ".gemini" / "settings.json" + assert settings.is_file(), "gemini の設定が置かれていない" + conf = json.loads(settings.read_text(encoding="utf-8")) + # 項目名は gemini の版で変わる。新旧どちらの形式でも書く + assert conf["context"]["fileFiltering"]["respectGitIgnore"] is False + assert conf["context"]["fileFiltering"]["respectGeminiIgnore"] is False + assert conf["fileFiltering"]["respectGitIgnore"] is False + assert conf["fileFiltering"]["respectGeminiIgnore"] is False + + +def test_gemini_settings_are_not_in_the_diff(repo): + _run(repo) + status = _git("status", "--short", cwd=repo["root"] / "gemini") + assert status.stdout.strip() == "", f"差分に現れている: {status.stdout}" + + +def test_only_gemini_gets_the_reading_setting(repo): + """他のランタイムの設定は触らない。""" + _run(repo) + for rt in ("codex", "kiro"): + assert not (repo["root"] / rt / ".gemini").exists() From 04316c58d5d65436bc1624057e65eea6f7e8f488 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 03:31:42 +0000 Subject: [PATCH 2/6] =?UTF-8?q?Fix:=20cross-review=20=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=AE=E5=AF=BE=E5=BF=9C=EF=BC=88=E7=B5=82=E4=BA=86=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E4=BC=9D=E6=92=AD=E3=81=A8=E5=8F=96=E3=82=8A?= =?UTF-8?q?=E6=B6=88=E3=81=97=E3=81=AE=E5=86=8D=E5=AE=9F=E8=A1=8C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `eval "$(rf ...)"` はコマンド置換のサブシェルで動くため `exit 4` が親へ伝わらない。 出力と終了コードを親シェルで受け取る `rf_eval` を追加し、init / start-round を移す - 取り消しより先に `merged_at` を立てていたため、取り消しに失敗して中断すると 次の実行が処理済みガードで素通りし、再試行できなかった。`pending_drop` を立てて から取り消しへ入り、push まで終えてから `merged_at` を立てる - やり残した取り消しは、処理済みの判定より先に再実行する(push だけ先に流さない) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GSwBvT9CH8mKfgyFn2JWfS --- .../skills/cross-refactoring/SKILL.md | 30 +++++- .../cross-refactoring/scripts/refactor.py | 102 ++++++++++++------ .../tests/test_merge_apply.py | 77 +++++++++++++ .../skills/cross-refactoring/SKILL.md | 30 +++++- .../cross-refactoring/scripts/refactor.py | 102 ++++++++++++------ .../tests/test_merge_apply.py | 77 +++++++++++++ .../skills/cross-refactoring/SKILL.md | 30 +++++- .../cross-refactoring/scripts/refactor.py | 102 ++++++++++++------ .../tests/test_merge_apply.py | 77 +++++++++++++ .../skills/cross-refactoring/SKILL.md | 30 +++++- .../cross-refactoring/scripts/refactor.py | 102 ++++++++++++------ .../tests/test_merge_apply.py | 77 +++++++++++++ 12 files changed, 692 insertions(+), 144 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/SKILL.md b/plugins/ndf-claude/skills/cross-refactoring/SKILL.md index bd01328d..68fdd069 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf-claude/skills/cross-refactoring/SKILL.md @@ -166,15 +166,30 @@ rf() { return $rc } -eval "$(rf init "$PR" --scope $SCOPE \ - --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ - --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ - --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS)" +# 出力を `eval` する呼び出しは**別の関数にする**。`eval "$(rf ...)"` と書くと `rf` は +# コマンド置換のサブシェルで動くため、`exit 4` はサブシェルしか終わらせない。 +# 外側の `eval` は空文字を評価して成功し、**中断したはずの進行がそのまま続く**。 +# 出力と終了コードを親シェルで受け取ってから判定する。 +rf_eval() { + local out rc + out=$("$SCRIPTS/refactor.py" "$@"); rc=$? + if [ $rc -eq 4 ]; then + echo "❌ cross-refactoring を中断しました(refactor.py $1)" >&2 + exit 4 + fi + eval "$out" + return $rc +} + +rf_eval init "$PR" --scope $SCOPE \ + --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ + --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ + --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" "$SCRIPTS/prepare-worktrees.sh" "$ID" while :; do # 提案ラウンドの繰り返し - eval "$(rf start-round "$ID")" || break # 終了コード 1 = 繰り返し終了 + rf_eval start-round "$ID" || break # 終了コード 1 = 繰り返し終了 for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done @@ -227,6 +242,11 @@ done | 3 | レビュー結果の形式不正 | 差し戻して再レビュー | | **4** | **中断**(取り消しの失敗、認証切れ、範囲を確定できないなど) | **進行ごと止める** | +出力を `eval` する呼び出し(`init` / `start-round`)は `rf_eval` を使う。 +`eval "$(rf ...)"` と書くと `rf` はコマンド置換のサブシェルで動くため、`exit 4` は +サブシェルしか終わらせず、外側の `eval` は空文字を評価して成功する。 +**中断したはずの進行がそのまま続く**ので、出力と終了コードは親シェルで受け取る。 + 続けて **Step 7** で `/ndf:cross-review ` を実行する。レビューはラウンド単位なので、 **ラウンドを跨いだ整合はここで見る**。収束したら Draft を解除し、 `refactor.py report "$ID" --metrics` の出力を報告する。 diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 74a99d3e..ed51c060 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1079,7 +1079,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: path, state = _load(args.id) entry = _round(state, args.round) if not args.dry_run: - _flush_pending_push(path, state, entry) + _resume_incomplete_apply(path, state, entry) # **叩き直しても同じ判定を返す。** 取り込み済みで再実行すると、前回作った # 取り消しコミットが「未割当」と判定され、成功した項目まで巻き込んで @@ -1292,8 +1292,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - # 取り込み済みの印。叩き直しでの二重処理を防ぐ。 - "merged_at": None if args.dry_run else statefile.now(), + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, } entry.setdefault("durations", {})["apply"] = _safe_int( payload.get("elapsed_seconds") @@ -1306,35 +1308,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: if failed: _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") - elif failed: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、検証を通っていない変更が Pull Request に残り、次の実行は - # 処理済みガードで素通りしてしまう。 - entry["pending_push"] = True - statefile.save(path, state) - result = _drop_items(state, entry, failed) - if result["mode"] == "round": - # 積み直せなかった。合意済みの項目も含めて全件捨てる。 - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it.setdefault( - "failure_reason", - "残す項目を積み直せなかったため、ラウンドごと取り消した", - ) - applied, failed = [], list(entry["items"]) - entry["apply"]["applied"] = applied - entry["apply"]["failed"] = failed - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - state["phase"] = "propose" - # 保存してから push する。push が失敗しても、記録とローカルの git が - # 食い違わない。 - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + applied = list(entry["apply"]["applied"]) else: + if failed: + applied = _apply_drop(path, state, entry, failed) + entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) if not applied: @@ -1342,6 +1320,68 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _apply_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + failed: list[str], +) -> list[str]: + """検証に失敗した項目を取り消し、採用として残る項目 ID を返す。 + + **中断しても再開できる形で記録する。** `pending_drop` を立ててから取り消しへ入り、 + push まで終わってから消す。取り消しに失敗して中断すると印が残るので、次の実行は + 処理済みの判定より先にここへ戻ってくる。印を立てずに `merged_at` を先に立てると、 + 次の実行は素通りして**取り消しを再試行できない**。 + """ + work = state["worktrees"]["work"] + entry["pending_drop"] = list(failed) + # 取り消しへ着手する**前に**再送信の印も立てる。取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + + result = _drop_items(state, entry, list(failed)) + applied = list(entry["apply"].get("applied") or []) + if result["mode"] == "round": + # 積み直せなかった。合意済みの項目も含めて全件捨てる。 + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it.setdefault( + "failure_reason", + "残す項目を積み直せなかったため、ラウンドごと取り消した", + ) + applied = [] + entry["apply"]["applied"] = [] + entry["apply"]["failed"] = list(entry["items"]) + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + + entry["pending_drop"] = [] + # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + return applied + + +def _resume_incomplete_apply( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any] +) -> None: + """前回終わらなかった取り消しと push を、処理済みの判定より**先に**片づける。 + + 取り消しをやり残したまま push だけ先に流すと、検証を通っていない HEAD が + Pull Request へ反映されてしまう。**取り消しの再実行を先に行う。** + """ + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + _apply_drop(path, state, entry, list(entry["pending_drop"])) + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + return + _flush_pending_push(path, state, entry) + + def cmd_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py index 3074e0fa..c7960120 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py @@ -1062,3 +1062,80 @@ def test_merge_apply_dry_run_leaves_no_processed_marker( }) refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": True})()) assert not (read_state(state_path)["rounds"][0]["apply"] or {}).get("merged_at") + + +def test_revert_failure_keeps_the_round_retryable( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しに失敗したら、処理済みの印を立てないこと。 + + 先に `merged_at` を立てると、次の実行は処理済みガードで素通りし、 + **取り消しを再試行できないまま**未検証の変更が Pull Request に残り続ける。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == refactor.ABORT + + entry = read_state(state_path)["rounds"][0] + assert entry["apply"]["merged_at"] is None, "処理済みの印が立っている" + assert entry["pending_drop"] == ["R1-002"], "再実行の対象が残っていない" + + +def test_pending_drop_is_retried_before_the_processed_guard( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """やり残した取り消しは、処理済みの判定より先に再実行すること。""" + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + # 2 回目は取り消しが通る状況を模す + calls, pushes = _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert [c[-1] for c in calls if c[:2] == ["git", "revert"]] == [ + "bad222", "bad111", "ok111"], "取り消しを再実行していない" + entry = read_state(state_path)["rounds"][0] + assert entry["pending_drop"] == [] + assert entry["pending_push"] is False + assert entry["apply"]["merged_at"] is not None + assert pushes, "再実行後に push していない" + + +def test_push_precedes_nothing_when_the_drop_is_unfinished( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しをやり残したまま push だけ先に流さないこと。 + + 未検証の HEAD をそのまま Pull Request へ反映してしまう。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + order: list[str] = [] + calls, _ = _drop_env(refactor, monkeypatch) + real_run = refactor.subprocess.run + monkeypatch.setattr( + refactor.subprocess, "run", + lambda cmd, **kw: (order.append(cmd[1]) if cmd[:1] == ["git"] else None) + or real_run(cmd, **kw), + ) + monkeypatch.setattr( + refactor, "_sh", lambda cmd, **k: order.append("push") or "") + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert "push" in order + assert order.index("revert") < order.index("push"), "取り消しより先に push している" + assert read_state(state_path)["rounds"][0]["apply"]["merged_at"] is not None diff --git a/plugins/ndf-codex/skills/cross-refactoring/SKILL.md b/plugins/ndf-codex/skills/cross-refactoring/SKILL.md index 8f642f55..9821cb8a 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf-codex/skills/cross-refactoring/SKILL.md @@ -166,15 +166,30 @@ rf() { return $rc } -eval "$(rf init "$PR" --scope $SCOPE \ - --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ - --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ - --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS)" +# 出力を `eval` する呼び出しは**別の関数にする**。`eval "$(rf ...)"` と書くと `rf` は +# コマンド置換のサブシェルで動くため、`exit 4` はサブシェルしか終わらせない。 +# 外側の `eval` は空文字を評価して成功し、**中断したはずの進行がそのまま続く**。 +# 出力と終了コードを親シェルで受け取ってから判定する。 +rf_eval() { + local out rc + out=$("$SCRIPTS/refactor.py" "$@"); rc=$? + if [ $rc -eq 4 ]; then + echo "❌ cross-refactoring を中断しました(refactor.py $1)" >&2 + exit 4 + fi + eval "$out" + return $rc +} + +rf_eval init "$PR" --scope $SCOPE \ + --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ + --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ + --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" "$SCRIPTS/prepare-worktrees.sh" "$ID" while :; do # 提案ラウンドの繰り返し - eval "$(rf start-round "$ID")" || break # 終了コード 1 = 繰り返し終了 + rf_eval start-round "$ID" || break # 終了コード 1 = 繰り返し終了 for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done @@ -227,6 +242,11 @@ done | 3 | レビュー結果の形式不正 | 差し戻して再レビュー | | **4** | **中断**(取り消しの失敗、認証切れ、範囲を確定できないなど) | **進行ごと止める** | +出力を `eval` する呼び出し(`init` / `start-round`)は `rf_eval` を使う。 +`eval "$(rf ...)"` と書くと `rf` はコマンド置換のサブシェルで動くため、`exit 4` は +サブシェルしか終わらせず、外側の `eval` は空文字を評価して成功する。 +**中断したはずの進行がそのまま続く**ので、出力と終了コードは親シェルで受け取る。 + 続けて **Step 7** で `/ndf:cross-review ` を実行する。レビューはラウンド単位なので、 **ラウンドを跨いだ整合はここで見る**。収束したら Draft を解除し、 `refactor.py report "$ID" --metrics` の出力を報告する。 diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 74a99d3e..ed51c060 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1079,7 +1079,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: path, state = _load(args.id) entry = _round(state, args.round) if not args.dry_run: - _flush_pending_push(path, state, entry) + _resume_incomplete_apply(path, state, entry) # **叩き直しても同じ判定を返す。** 取り込み済みで再実行すると、前回作った # 取り消しコミットが「未割当」と判定され、成功した項目まで巻き込んで @@ -1292,8 +1292,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - # 取り込み済みの印。叩き直しでの二重処理を防ぐ。 - "merged_at": None if args.dry_run else statefile.now(), + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, } entry.setdefault("durations", {})["apply"] = _safe_int( payload.get("elapsed_seconds") @@ -1306,35 +1308,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: if failed: _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") - elif failed: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、検証を通っていない変更が Pull Request に残り、次の実行は - # 処理済みガードで素通りしてしまう。 - entry["pending_push"] = True - statefile.save(path, state) - result = _drop_items(state, entry, failed) - if result["mode"] == "round": - # 積み直せなかった。合意済みの項目も含めて全件捨てる。 - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it.setdefault( - "failure_reason", - "残す項目を積み直せなかったため、ラウンドごと取り消した", - ) - applied, failed = [], list(entry["items"]) - entry["apply"]["applied"] = applied - entry["apply"]["failed"] = failed - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - state["phase"] = "propose" - # 保存してから push する。push が失敗しても、記録とローカルの git が - # 食い違わない。 - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + applied = list(entry["apply"]["applied"]) else: + if failed: + applied = _apply_drop(path, state, entry, failed) + entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) if not applied: @@ -1342,6 +1320,68 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _apply_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + failed: list[str], +) -> list[str]: + """検証に失敗した項目を取り消し、採用として残る項目 ID を返す。 + + **中断しても再開できる形で記録する。** `pending_drop` を立ててから取り消しへ入り、 + push まで終わってから消す。取り消しに失敗して中断すると印が残るので、次の実行は + 処理済みの判定より先にここへ戻ってくる。印を立てずに `merged_at` を先に立てると、 + 次の実行は素通りして**取り消しを再試行できない**。 + """ + work = state["worktrees"]["work"] + entry["pending_drop"] = list(failed) + # 取り消しへ着手する**前に**再送信の印も立てる。取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + + result = _drop_items(state, entry, list(failed)) + applied = list(entry["apply"].get("applied") or []) + if result["mode"] == "round": + # 積み直せなかった。合意済みの項目も含めて全件捨てる。 + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it.setdefault( + "failure_reason", + "残す項目を積み直せなかったため、ラウンドごと取り消した", + ) + applied = [] + entry["apply"]["applied"] = [] + entry["apply"]["failed"] = list(entry["items"]) + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + + entry["pending_drop"] = [] + # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + return applied + + +def _resume_incomplete_apply( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any] +) -> None: + """前回終わらなかった取り消しと push を、処理済みの判定より**先に**片づける。 + + 取り消しをやり残したまま push だけ先に流すと、検証を通っていない HEAD が + Pull Request へ反映されてしまう。**取り消しの再実行を先に行う。** + """ + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + _apply_drop(path, state, entry, list(entry["pending_drop"])) + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + return + _flush_pending_push(path, state, entry) + + def cmd_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py index 3074e0fa..c7960120 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py @@ -1062,3 +1062,80 @@ def test_merge_apply_dry_run_leaves_no_processed_marker( }) refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": True})()) assert not (read_state(state_path)["rounds"][0]["apply"] or {}).get("merged_at") + + +def test_revert_failure_keeps_the_round_retryable( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しに失敗したら、処理済みの印を立てないこと。 + + 先に `merged_at` を立てると、次の実行は処理済みガードで素通りし、 + **取り消しを再試行できないまま**未検証の変更が Pull Request に残り続ける。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == refactor.ABORT + + entry = read_state(state_path)["rounds"][0] + assert entry["apply"]["merged_at"] is None, "処理済みの印が立っている" + assert entry["pending_drop"] == ["R1-002"], "再実行の対象が残っていない" + + +def test_pending_drop_is_retried_before_the_processed_guard( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """やり残した取り消しは、処理済みの判定より先に再実行すること。""" + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + # 2 回目は取り消しが通る状況を模す + calls, pushes = _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert [c[-1] for c in calls if c[:2] == ["git", "revert"]] == [ + "bad222", "bad111", "ok111"], "取り消しを再実行していない" + entry = read_state(state_path)["rounds"][0] + assert entry["pending_drop"] == [] + assert entry["pending_push"] is False + assert entry["apply"]["merged_at"] is not None + assert pushes, "再実行後に push していない" + + +def test_push_precedes_nothing_when_the_drop_is_unfinished( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しをやり残したまま push だけ先に流さないこと。 + + 未検証の HEAD をそのまま Pull Request へ反映してしまう。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + order: list[str] = [] + calls, _ = _drop_env(refactor, monkeypatch) + real_run = refactor.subprocess.run + monkeypatch.setattr( + refactor.subprocess, "run", + lambda cmd, **kw: (order.append(cmd[1]) if cmd[:1] == ["git"] else None) + or real_run(cmd, **kw), + ) + monkeypatch.setattr( + refactor, "_sh", lambda cmd, **k: order.append("push") or "") + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert "push" in order + assert order.index("revert") < order.index("push"), "取り消しより先に push している" + assert read_state(state_path)["rounds"][0]["apply"]["merged_at"] is not None diff --git a/plugins/ndf-kiro/skills/cross-refactoring/SKILL.md b/plugins/ndf-kiro/skills/cross-refactoring/SKILL.md index 7713b6b2..aab37aa8 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf-kiro/skills/cross-refactoring/SKILL.md @@ -166,15 +166,30 @@ rf() { return $rc } -eval "$(rf init "$PR" --scope $SCOPE \ - --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ - --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ - --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS)" +# 出力を `eval` する呼び出しは**別の関数にする**。`eval "$(rf ...)"` と書くと `rf` は +# コマンド置換のサブシェルで動くため、`exit 4` はサブシェルしか終わらせない。 +# 外側の `eval` は空文字を評価して成功し、**中断したはずの進行がそのまま続く**。 +# 出力と終了コードを親シェルで受け取ってから判定する。 +rf_eval() { + local out rc + out=$("$SCRIPTS/refactor.py" "$@"); rc=$? + if [ $rc -eq 4 ]; then + echo "❌ cross-refactoring を中断しました(refactor.py $1)" >&2 + exit 4 + fi + eval "$out" + return $rc +} + +rf_eval init "$PR" --scope $SCOPE \ + --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ + --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ + --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" "$SCRIPTS/prepare-worktrees.sh" "$ID" while :; do # 提案ラウンドの繰り返し - eval "$(rf start-round "$ID")" || break # 終了コード 1 = 繰り返し終了 + rf_eval start-round "$ID" || break # 終了コード 1 = 繰り返し終了 for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done @@ -227,6 +242,11 @@ done | 3 | レビュー結果の形式不正 | 差し戻して再レビュー | | **4** | **中断**(取り消しの失敗、認証切れ、範囲を確定できないなど) | **進行ごと止める** | +出力を `eval` する呼び出し(`init` / `start-round`)は `rf_eval` を使う。 +`eval "$(rf ...)"` と書くと `rf` はコマンド置換のサブシェルで動くため、`exit 4` は +サブシェルしか終わらせず、外側の `eval` は空文字を評価して成功する。 +**中断したはずの進行がそのまま続く**ので、出力と終了コードは親シェルで受け取る。 + 続けて **Step 7** で `/ndf:cross-review ` を実行する。レビューはラウンド単位なので、 **ラウンドを跨いだ整合はここで見る**。収束したら Draft を解除し、 `refactor.py report "$ID" --metrics` の出力を報告する。 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 74a99d3e..ed51c060 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1079,7 +1079,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: path, state = _load(args.id) entry = _round(state, args.round) if not args.dry_run: - _flush_pending_push(path, state, entry) + _resume_incomplete_apply(path, state, entry) # **叩き直しても同じ判定を返す。** 取り込み済みで再実行すると、前回作った # 取り消しコミットが「未割当」と判定され、成功した項目まで巻き込んで @@ -1292,8 +1292,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - # 取り込み済みの印。叩き直しでの二重処理を防ぐ。 - "merged_at": None if args.dry_run else statefile.now(), + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, } entry.setdefault("durations", {})["apply"] = _safe_int( payload.get("elapsed_seconds") @@ -1306,35 +1308,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: if failed: _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") - elif failed: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、検証を通っていない変更が Pull Request に残り、次の実行は - # 処理済みガードで素通りしてしまう。 - entry["pending_push"] = True - statefile.save(path, state) - result = _drop_items(state, entry, failed) - if result["mode"] == "round": - # 積み直せなかった。合意済みの項目も含めて全件捨てる。 - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it.setdefault( - "failure_reason", - "残す項目を積み直せなかったため、ラウンドごと取り消した", - ) - applied, failed = [], list(entry["items"]) - entry["apply"]["applied"] = applied - entry["apply"]["failed"] = failed - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - state["phase"] = "propose" - # 保存してから push する。push が失敗しても、記録とローカルの git が - # 食い違わない。 - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + applied = list(entry["apply"]["applied"]) else: + if failed: + applied = _apply_drop(path, state, entry, failed) + entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) if not applied: @@ -1342,6 +1320,68 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _apply_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + failed: list[str], +) -> list[str]: + """検証に失敗した項目を取り消し、採用として残る項目 ID を返す。 + + **中断しても再開できる形で記録する。** `pending_drop` を立ててから取り消しへ入り、 + push まで終わってから消す。取り消しに失敗して中断すると印が残るので、次の実行は + 処理済みの判定より先にここへ戻ってくる。印を立てずに `merged_at` を先に立てると、 + 次の実行は素通りして**取り消しを再試行できない**。 + """ + work = state["worktrees"]["work"] + entry["pending_drop"] = list(failed) + # 取り消しへ着手する**前に**再送信の印も立てる。取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + + result = _drop_items(state, entry, list(failed)) + applied = list(entry["apply"].get("applied") or []) + if result["mode"] == "round": + # 積み直せなかった。合意済みの項目も含めて全件捨てる。 + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it.setdefault( + "failure_reason", + "残す項目を積み直せなかったため、ラウンドごと取り消した", + ) + applied = [] + entry["apply"]["applied"] = [] + entry["apply"]["failed"] = list(entry["items"]) + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + + entry["pending_drop"] = [] + # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + return applied + + +def _resume_incomplete_apply( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any] +) -> None: + """前回終わらなかった取り消しと push を、処理済みの判定より**先に**片づける。 + + 取り消しをやり残したまま push だけ先に流すと、検証を通っていない HEAD が + Pull Request へ反映されてしまう。**取り消しの再実行を先に行う。** + """ + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + _apply_drop(path, state, entry, list(entry["pending_drop"])) + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + return + _flush_pending_push(path, state, entry) + + def cmd_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py index 3074e0fa..c7960120 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py @@ -1062,3 +1062,80 @@ def test_merge_apply_dry_run_leaves_no_processed_marker( }) refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": True})()) assert not (read_state(state_path)["rounds"][0]["apply"] or {}).get("merged_at") + + +def test_revert_failure_keeps_the_round_retryable( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しに失敗したら、処理済みの印を立てないこと。 + + 先に `merged_at` を立てると、次の実行は処理済みガードで素通りし、 + **取り消しを再試行できないまま**未検証の変更が Pull Request に残り続ける。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == refactor.ABORT + + entry = read_state(state_path)["rounds"][0] + assert entry["apply"]["merged_at"] is None, "処理済みの印が立っている" + assert entry["pending_drop"] == ["R1-002"], "再実行の対象が残っていない" + + +def test_pending_drop_is_retried_before_the_processed_guard( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """やり残した取り消しは、処理済みの判定より先に再実行すること。""" + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + # 2 回目は取り消しが通る状況を模す + calls, pushes = _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert [c[-1] for c in calls if c[:2] == ["git", "revert"]] == [ + "bad222", "bad111", "ok111"], "取り消しを再実行していない" + entry = read_state(state_path)["rounds"][0] + assert entry["pending_drop"] == [] + assert entry["pending_push"] is False + assert entry["apply"]["merged_at"] is not None + assert pushes, "再実行後に push していない" + + +def test_push_precedes_nothing_when_the_drop_is_unfinished( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しをやり残したまま push だけ先に流さないこと。 + + 未検証の HEAD をそのまま Pull Request へ反映してしまう。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + order: list[str] = [] + calls, _ = _drop_env(refactor, monkeypatch) + real_run = refactor.subprocess.run + monkeypatch.setattr( + refactor.subprocess, "run", + lambda cmd, **kw: (order.append(cmd[1]) if cmd[:1] == ["git"] else None) + or real_run(cmd, **kw), + ) + monkeypatch.setattr( + refactor, "_sh", lambda cmd, **k: order.append("push") or "") + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert "push" in order + assert order.index("revert") < order.index("push"), "取り消しより先に push している" + assert read_state(state_path)["rounds"][0]["apply"]["merged_at"] is not None diff --git a/plugins/ndf-shared/skills/cross-refactoring/SKILL.md b/plugins/ndf-shared/skills/cross-refactoring/SKILL.md index bd01328d..68fdd069 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf-shared/skills/cross-refactoring/SKILL.md @@ -166,15 +166,30 @@ rf() { return $rc } -eval "$(rf init "$PR" --scope $SCOPE \ - --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ - --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ - --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS)" +# 出力を `eval` する呼び出しは**別の関数にする**。`eval "$(rf ...)"` と書くと `rf` は +# コマンド置換のサブシェルで動くため、`exit 4` はサブシェルしか終わらせない。 +# 外側の `eval` は空文字を評価して成功し、**中断したはずの進行がそのまま続く**。 +# 出力と終了コードを親シェルで受け取ってから判定する。 +rf_eval() { + local out rc + out=$("$SCRIPTS/refactor.py" "$@"); rc=$? + if [ $rc -eq 4 ]; then + echo "❌ cross-refactoring を中断しました(refactor.py $1)" >&2 + exit 4 + fi + eval "$out" + return $rc +} + +rf_eval init "$PR" --scope $SCOPE \ + --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ + --max-outer-rounds "$MAX_OUTER" --max-fix-rounds "$MAX_FIX" \ + --max-items-per-round "$MAX_ITEMS" $MODEL_ARGS export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" "$SCRIPTS/prepare-worktrees.sh" "$ID" while :; do # 提案ラウンドの繰り返し - eval "$(rf start-round "$ID")" || break # 終了コード 1 = 繰り返し終了 + rf_eval start-round "$ID" || break # 終了コード 1 = 繰り返し終了 for a in $RUNTIMES; do "$SCRIPTS/launch-cli.sh" "$a" propose "$ID" "$ROUND" done @@ -227,6 +242,11 @@ done | 3 | レビュー結果の形式不正 | 差し戻して再レビュー | | **4** | **中断**(取り消しの失敗、認証切れ、範囲を確定できないなど) | **進行ごと止める** | +出力を `eval` する呼び出し(`init` / `start-round`)は `rf_eval` を使う。 +`eval "$(rf ...)"` と書くと `rf` はコマンド置換のサブシェルで動くため、`exit 4` は +サブシェルしか終わらせず、外側の `eval` は空文字を評価して成功する。 +**中断したはずの進行がそのまま続く**ので、出力と終了コードは親シェルで受け取る。 + 続けて **Step 7** で `/ndf:cross-review ` を実行する。レビューはラウンド単位なので、 **ラウンドを跨いだ整合はここで見る**。収束したら Draft を解除し、 `refactor.py report "$ID" --metrics` の出力を報告する。 diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 74a99d3e..ed51c060 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1079,7 +1079,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: path, state = _load(args.id) entry = _round(state, args.round) if not args.dry_run: - _flush_pending_push(path, state, entry) + _resume_incomplete_apply(path, state, entry) # **叩き直しても同じ判定を返す。** 取り込み済みで再実行すると、前回作った # 取り消しコミットが「未割当」と判定され、成功した項目まで巻き込んで @@ -1292,8 +1292,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - # 取り込み済みの印。叩き直しでの二重処理を防ぐ。 - "merged_at": None if args.dry_run else statefile.now(), + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, } entry.setdefault("durations", {})["apply"] = _safe_int( payload.get("elapsed_seconds") @@ -1306,35 +1308,11 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: if failed: _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") - elif failed: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、検証を通っていない変更が Pull Request に残り、次の実行は - # 処理済みガードで素通りしてしまう。 - entry["pending_push"] = True - statefile.save(path, state) - result = _drop_items(state, entry, failed) - if result["mode"] == "round": - # 積み直せなかった。合意済みの項目も含めて全件捨てる。 - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it.setdefault( - "failure_reason", - "残す項目を積み直せなかったため、ラウンドごと取り消した", - ) - applied, failed = [], list(entry["items"]) - entry["apply"]["applied"] = applied - entry["apply"]["failed"] = failed - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - state["phase"] = "propose" - # 保存してから push する。push が失敗しても、記録とローカルの git が - # 食い違わない。 - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) + applied = list(entry["apply"]["applied"]) else: + if failed: + applied = _apply_drop(path, state, entry, failed) + entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) if not applied: @@ -1342,6 +1320,68 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _apply_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + failed: list[str], +) -> list[str]: + """検証に失敗した項目を取り消し、採用として残る項目 ID を返す。 + + **中断しても再開できる形で記録する。** `pending_drop` を立ててから取り消しへ入り、 + push まで終わってから消す。取り消しに失敗して中断すると印が残るので、次の実行は + 処理済みの判定より先にここへ戻ってくる。印を立てずに `merged_at` を先に立てると、 + 次の実行は素通りして**取り消しを再試行できない**。 + """ + work = state["worktrees"]["work"] + entry["pending_drop"] = list(failed) + # 取り消しへ着手する**前に**再送信の印も立てる。取り消しは済んだのに push + # できずに終わると、未検証の変更が Pull Request に残ったままになる。 + entry["pending_push"] = True + statefile.save(path, state) + + result = _drop_items(state, entry, list(failed)) + applied = list(entry["apply"].get("applied") or []) + if result["mode"] == "round": + # 積み直せなかった。合意済みの項目も含めて全件捨てる。 + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it.setdefault( + "failure_reason", + "残す項目を積み直せなかったため、ラウンドごと取り消した", + ) + applied = [] + entry["apply"]["applied"] = [] + entry["apply"]["failed"] = list(entry["items"]) + # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + state["phase"] = "propose" + + entry["pending_drop"] = [] + # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + return applied + + +def _resume_incomplete_apply( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any] +) -> None: + """前回終わらなかった取り消しと push を、処理済みの判定より**先に**片づける。 + + 取り消しをやり残したまま push だけ先に流すと、検証を通っていない HEAD が + Pull Request へ反映されてしまう。**取り消しの再実行を先に行う。** + """ + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + _apply_drop(path, state, entry, list(entry["pending_drop"])) + entry["apply"]["merged_at"] = statefile.now() + statefile.save(path, state) + return + _flush_pending_push(path, state, entry) + + def cmd_judge_review(args: argparse.Namespace) -> None: """Step 5 — レビュー 2 者の判定を取り込む。 diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py index 3074e0fa..c7960120 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py @@ -1062,3 +1062,80 @@ def test_merge_apply_dry_run_leaves_no_processed_marker( }) refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": True})()) assert not (read_state(state_path)["rounds"][0]["apply"] or {}).get("merged_at") + + +def test_revert_failure_keeps_the_round_retryable( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しに失敗したら、処理済みの印を立てないこと。 + + 先に `merged_at` を立てると、次の実行は処理済みガードで素通りし、 + **取り消しを再試行できないまま**未検証の変更が Pull Request に残り続ける。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + assert e.value.code == refactor.ABORT + + entry = read_state(state_path)["rounds"][0] + assert entry["apply"]["merged_at"] is None, "処理済みの印が立っている" + assert entry["pending_drop"] == ["R1-002"], "再実行の対象が残っていない" + + +def test_pending_drop_is_retried_before_the_processed_guard( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """やり残した取り消しは、処理済みの判定より先に再実行すること。""" + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + # 2 回目は取り消しが通る状況を模す + calls, pushes = _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert [c[-1] for c in calls if c[:2] == ["git", "revert"]] == [ + "bad222", "bad111", "ok111"], "取り消しを再実行していない" + entry = read_state(state_path)["rounds"][0] + assert entry["pending_drop"] == [] + assert entry["pending_push"] is False + assert entry["apply"]["merged_at"] is not None + assert pushes, "再実行後に push していない" + + +def test_push_precedes_nothing_when_the_drop_is_unfinished( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しをやり残したまま push だけ先に流さないこと。 + + 未検証の HEAD をそのまま Pull Request へ反映してしまう。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch, revert_rc=1) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + order: list[str] = [] + calls, _ = _drop_env(refactor, monkeypatch) + real_run = refactor.subprocess.run + monkeypatch.setattr( + refactor.subprocess, "run", + lambda cmd, **kw: (order.append(cmd[1]) if cmd[:1] == ["git"] else None) + or real_run(cmd, **kw), + ) + monkeypatch.setattr( + refactor, "_sh", lambda cmd, **k: order.append("push") or "") + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert "push" in order + assert order.index("revert") < order.index("push"), "取り消しより先に push している" + assert read_state(state_path)["rounds"][0]["apply"]["merged_at"] is not None From a3f2ca537941cb2cee80ef14162fcfb52a191724 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 03:40:07 +0000 Subject: [PATCH 3/6] =?UTF-8?q?Fix:=20cross-review=20=E6=8C=87=E6=91=98?= =?UTF-8?q?=E3=81=AE=E5=AF=BE=E5=BF=9C=EF=BC=88push=20=E5=A4=B1=E6=95=97?= =?UTF-8?q?=E5=BE=8C=E3=81=AE=E5=86=8D=E9=96=8B=E3=81=A8=E5=8F=96=E3=82=8A?= =?UTF-8?q?=E6=B6=88=E3=81=97=E3=81=AE=E4=BA=8C=E9=87=8D=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 取り消しが済んだことを push より先に永続化する。保存せずに push して失敗すると、 次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを「未割当」と 判定してラウンドごと巻き込んでいた - 積み直しに失敗したときは、着手前ではなく**取り消しが済んだ地点**へ戻す。 着手前まで戻して取り消しをやり直すと、同じ範囲の revert コミットが 2 組できる Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GSwBvT9CH8mKfgyFn2JWfS --- .../cross-refactoring/scripts/refactor.py | 36 +++++++++---- .../tests/test_drop_items_git.py | 6 +++ .../tests/test_merge_apply.py | 50 ++++++++++++++++++- .../cross-refactoring/scripts/refactor.py | 36 +++++++++---- .../tests/test_drop_items_git.py | 6 +++ .../tests/test_merge_apply.py | 50 ++++++++++++++++++- .../cross-refactoring/scripts/refactor.py | 36 +++++++++---- .../tests/test_drop_items_git.py | 6 +++ .../tests/test_merge_apply.py | 50 ++++++++++++++++++- .../cross-refactoring/scripts/refactor.py | 36 +++++++++---- .../tests/test_drop_items_git.py | 6 +++ .../tests/test_merge_apply.py | 50 ++++++++++++++++++- 12 files changed, 316 insertions(+), 52 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index ed51c060..a77a6767 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1309,9 +1309,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") applied = list(entry["apply"]["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(path, state, entry, failed) else: - if failed: - applied = _apply_drop(path, state, entry, failed) entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) @@ -1326,10 +1327,18 @@ def _apply_drop( ) -> list[str]: """検証に失敗した項目を取り消し、採用として残る項目 ID を返す。 - **中断しても再開できる形で記録する。** `pending_drop` を立ててから取り消しへ入り、 - push まで終わってから消す。取り消しに失敗して中断すると印が残るので、次の実行は - 処理済みの判定より先にここへ戻ってくる。印を立てずに `merged_at` を先に立てると、 - 次の実行は素通りして**取り消しを再試行できない**。 + **中断しても再開できる形で記録する。** 失敗の位置で必要な再開が変わるため、 + 印は次の順で切り替える。 + + | 中断した位置 | 残る印 | 次の実行がすること | + | --- | --- | --- | + | 取り消しの途中 | `pending_drop` あり / `merged_at` なし | 取り消しをやり直す | + | 取り消し後・push 前 | `pending_drop` なし / `merged_at` あり / `pending_push` あり | **push の再送だけ** | + + 取り消しより先に `merged_at` を立てると、取り消しに失敗したときに次の実行が + 処理済みガードで素通りし、**再試行できない**。逆に push まで終えるまで + `merged_at` を立てないと、push だけ失敗したときに次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 """ work = state["worktrees"]["work"] entry["pending_drop"] = list(failed) @@ -1357,7 +1366,11 @@ def _apply_drop( state["phase"] = "propose" entry["pending_drop"] = [] - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 + # **取り消しが済んだことを push より先に永続化する。** 保存せずに push して + # 失敗すると、次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを + # 「未割当」と判定してラウンドごと巻き込んでしまう。`pending_push` は残るので + # 次の実行は push の再送だけを行う。 + entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) _push_head(state) entry["pending_push"] = False @@ -1376,8 +1389,6 @@ def _resume_incomplete_apply( if entry.get("pending_drop"): info("↻ 前回終わらなかった取り消しを再実行します") _apply_drop(path, state, entry, list(entry["pending_drop"])) - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) return _flush_pending_push(path, state, entry) @@ -2373,12 +2384,15 @@ def _drop_items( "reverted": len(ordered), "replayed": len(replay)} _revert_range(work, ordered, head) + # 取り消しが済んだ地点。積み直しに失敗したらここへ戻せばよい。 + reverted_head = _git_out(work, ["rev-parse", "HEAD"]) mapping = _replay_commits(work, replay) mode = "item" if mapping is None: info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") - _reset_hard(work, head) - _revert_range(work, ordered, head) + # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが + # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 + _reset_hard(work, reverted_head) mapping, mode = {}, "round" dropped = list(entry["items"]) if mode == "round" else pending diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_drop_items_git.py index bf828a03..de3759e5 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_drop_items_git.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_drop_items_git.py @@ -189,12 +189,18 @@ def test_adjacent_changes_fall_back_to_the_whole_round(refactor, adjacent_repo): 半端な履歴を残すより、決定的な状態へ落とす方が安全である。 """ state, entry = _state(adjacent_repo) + before = int(_git("rev-list", "--count", "HEAD", + cwd=adjacent_repo["repo"]).stdout.strip()) result = refactor._drop_items(state, entry, ["R1-001"]) assert result["mode"] == "round" assert _content(adjacent_repo) == "".join(LINES), "着手前の内容へ戻っていない" assert all(i["reverted"] for i in state["items"]) assert entry["drops"][-1]["mode"] == "round" + # 取り消しは 1 組だけ。着手前まで戻してやり直すと 2 組できて履歴が汚れる + after = int(_git("rev-list", "--count", "HEAD", + cwd=adjacent_repo["repo"]).stdout.strip()) + assert after - before == 2, f"取り消しコミットが余分にある({after - before} 件)" def test_dropping_every_item_returns_to_the_base_tree(refactor, distant_repo): diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py index c7960120..f8dd5d56 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py @@ -391,12 +391,15 @@ def _drop_env(refactor, monkeypatch, revert_rc=0, pick_rc=0): """ calls: list[list[str]] = [] picked: list[str] = [] + reverted: list[str] = [] def fake_run(cmd, **kwargs): calls.append(list(cmd)) rc = 0 if cmd[:2] == ["git", "revert"] and "--abort" not in cmd: rc = revert_rc + if rc == 0: + reverted.append(cmd[-1]) if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: rc = pick_rc if rc == 0: @@ -407,7 +410,9 @@ def fake_git_out(work, args): if args[:2] == ["rev-parse", "--verify"]: return args[-1].replace("^{commit}", "") if args == ["rev-parse", "HEAD"]: - return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + if picked: + return f"new-{picked[-1]}" + return "REVERTED_HEAD" if reverted else "HEAD_BEFORE" return "HEAD_BEFORE" monkeypatch.setattr(refactor.subprocess, "run", fake_run) @@ -491,7 +496,11 @@ def test_replay_conflict_falls_back_to_whole_round( assert e.value.code == 2, "全件失敗として次の提案ラウンドへ進むこと" assert ["git", "cherry-pick", "--abort"] in calls - assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + # 取り消しが済んだ地点へ戻すだけ。着手前まで戻して取り消しをやり直すと、 + # 同じ範囲の revert コミットが 2 組できて履歴が汚れる + assert ["git", "reset", "--hard", "REVERTED_HEAD"] in calls + reverts = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + assert reverts == ["bad222", "bad111", "ok111"], "取り消しを 2 度走らせている" state = read_state(state_path) assert all(i["status"] == "abandoned" for i in state["items"]) assert state["rounds"][0]["apply"]["applied"] == [] @@ -1139,3 +1148,40 @@ def test_push_precedes_nothing_when_the_drop_is_unfinished( assert "push" in order assert order.index("revert") < order.index("push"), "取り消しより先に push している" assert read_state(state_path)["rounds"][0]["apply"]["merged_at"] is not None + + +def test_push_failure_after_a_successful_drop_only_retries_the_push( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しが済んだあとに push だけ失敗したら、次は push の再送だけを行うこと。 + + 取り消しの完了を push より先に永続化しないと、次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch) + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: (_ for _ in ()).throw(SystemExit(4)) + if cmd[:2] == ["git", "push"] else "", + ) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + entry = read_state(state_path)["rounds"][0] + assert entry["pending_drop"] == [], "取り消しは済んでいるのに再実行の対象が残っている" + assert entry["apply"]["merged_at"] is not None, "取り消しの完了が保存されていない" + assert entry["pending_push"] is True + + # 2 回目: push が通る。取り消しは繰り返さない + calls, pushes = _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert [c for c in calls if c[:2] == ["git", "revert"]] == [], "取り消しを繰り返している" + assert [c for c in calls if c[:2] == ["git", "cherry-pick"]] == [] + assert [c for c in pushes if c[:2] == ["git", "push"]], "push を再送していない" + entry = read_state(state_path)["rounds"][0] + assert entry["pending_push"] is False + assert entry["apply"]["applied"] == ["R1-001"] diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index ed51c060..a77a6767 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1309,9 +1309,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") applied = list(entry["apply"]["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(path, state, entry, failed) else: - if failed: - applied = _apply_drop(path, state, entry, failed) entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) @@ -1326,10 +1327,18 @@ def _apply_drop( ) -> list[str]: """検証に失敗した項目を取り消し、採用として残る項目 ID を返す。 - **中断しても再開できる形で記録する。** `pending_drop` を立ててから取り消しへ入り、 - push まで終わってから消す。取り消しに失敗して中断すると印が残るので、次の実行は - 処理済みの判定より先にここへ戻ってくる。印を立てずに `merged_at` を先に立てると、 - 次の実行は素通りして**取り消しを再試行できない**。 + **中断しても再開できる形で記録する。** 失敗の位置で必要な再開が変わるため、 + 印は次の順で切り替える。 + + | 中断した位置 | 残る印 | 次の実行がすること | + | --- | --- | --- | + | 取り消しの途中 | `pending_drop` あり / `merged_at` なし | 取り消しをやり直す | + | 取り消し後・push 前 | `pending_drop` なし / `merged_at` あり / `pending_push` あり | **push の再送だけ** | + + 取り消しより先に `merged_at` を立てると、取り消しに失敗したときに次の実行が + 処理済みガードで素通りし、**再試行できない**。逆に push まで終えるまで + `merged_at` を立てないと、push だけ失敗したときに次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 """ work = state["worktrees"]["work"] entry["pending_drop"] = list(failed) @@ -1357,7 +1366,11 @@ def _apply_drop( state["phase"] = "propose" entry["pending_drop"] = [] - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 + # **取り消しが済んだことを push より先に永続化する。** 保存せずに push して + # 失敗すると、次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを + # 「未割当」と判定してラウンドごと巻き込んでしまう。`pending_push` は残るので + # 次の実行は push の再送だけを行う。 + entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) _push_head(state) entry["pending_push"] = False @@ -1376,8 +1389,6 @@ def _resume_incomplete_apply( if entry.get("pending_drop"): info("↻ 前回終わらなかった取り消しを再実行します") _apply_drop(path, state, entry, list(entry["pending_drop"])) - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) return _flush_pending_push(path, state, entry) @@ -2373,12 +2384,15 @@ def _drop_items( "reverted": len(ordered), "replayed": len(replay)} _revert_range(work, ordered, head) + # 取り消しが済んだ地点。積み直しに失敗したらここへ戻せばよい。 + reverted_head = _git_out(work, ["rev-parse", "HEAD"]) mapping = _replay_commits(work, replay) mode = "item" if mapping is None: info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") - _reset_hard(work, head) - _revert_range(work, ordered, head) + # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが + # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 + _reset_hard(work, reverted_head) mapping, mode = {}, "round" dropped = list(entry["items"]) if mode == "round" else pending diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_drop_items_git.py index bf828a03..de3759e5 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_drop_items_git.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_drop_items_git.py @@ -189,12 +189,18 @@ def test_adjacent_changes_fall_back_to_the_whole_round(refactor, adjacent_repo): 半端な履歴を残すより、決定的な状態へ落とす方が安全である。 """ state, entry = _state(adjacent_repo) + before = int(_git("rev-list", "--count", "HEAD", + cwd=adjacent_repo["repo"]).stdout.strip()) result = refactor._drop_items(state, entry, ["R1-001"]) assert result["mode"] == "round" assert _content(adjacent_repo) == "".join(LINES), "着手前の内容へ戻っていない" assert all(i["reverted"] for i in state["items"]) assert entry["drops"][-1]["mode"] == "round" + # 取り消しは 1 組だけ。着手前まで戻してやり直すと 2 組できて履歴が汚れる + after = int(_git("rev-list", "--count", "HEAD", + cwd=adjacent_repo["repo"]).stdout.strip()) + assert after - before == 2, f"取り消しコミットが余分にある({after - before} 件)" def test_dropping_every_item_returns_to_the_base_tree(refactor, distant_repo): diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py index c7960120..f8dd5d56 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py @@ -391,12 +391,15 @@ def _drop_env(refactor, monkeypatch, revert_rc=0, pick_rc=0): """ calls: list[list[str]] = [] picked: list[str] = [] + reverted: list[str] = [] def fake_run(cmd, **kwargs): calls.append(list(cmd)) rc = 0 if cmd[:2] == ["git", "revert"] and "--abort" not in cmd: rc = revert_rc + if rc == 0: + reverted.append(cmd[-1]) if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: rc = pick_rc if rc == 0: @@ -407,7 +410,9 @@ def fake_git_out(work, args): if args[:2] == ["rev-parse", "--verify"]: return args[-1].replace("^{commit}", "") if args == ["rev-parse", "HEAD"]: - return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + if picked: + return f"new-{picked[-1]}" + return "REVERTED_HEAD" if reverted else "HEAD_BEFORE" return "HEAD_BEFORE" monkeypatch.setattr(refactor.subprocess, "run", fake_run) @@ -491,7 +496,11 @@ def test_replay_conflict_falls_back_to_whole_round( assert e.value.code == 2, "全件失敗として次の提案ラウンドへ進むこと" assert ["git", "cherry-pick", "--abort"] in calls - assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + # 取り消しが済んだ地点へ戻すだけ。着手前まで戻して取り消しをやり直すと、 + # 同じ範囲の revert コミットが 2 組できて履歴が汚れる + assert ["git", "reset", "--hard", "REVERTED_HEAD"] in calls + reverts = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + assert reverts == ["bad222", "bad111", "ok111"], "取り消しを 2 度走らせている" state = read_state(state_path) assert all(i["status"] == "abandoned" for i in state["items"]) assert state["rounds"][0]["apply"]["applied"] == [] @@ -1139,3 +1148,40 @@ def test_push_precedes_nothing_when_the_drop_is_unfinished( assert "push" in order assert order.index("revert") < order.index("push"), "取り消しより先に push している" assert read_state(state_path)["rounds"][0]["apply"]["merged_at"] is not None + + +def test_push_failure_after_a_successful_drop_only_retries_the_push( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しが済んだあとに push だけ失敗したら、次は push の再送だけを行うこと。 + + 取り消しの完了を push より先に永続化しないと、次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch) + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: (_ for _ in ()).throw(SystemExit(4)) + if cmd[:2] == ["git", "push"] else "", + ) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + entry = read_state(state_path)["rounds"][0] + assert entry["pending_drop"] == [], "取り消しは済んでいるのに再実行の対象が残っている" + assert entry["apply"]["merged_at"] is not None, "取り消しの完了が保存されていない" + assert entry["pending_push"] is True + + # 2 回目: push が通る。取り消しは繰り返さない + calls, pushes = _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert [c for c in calls if c[:2] == ["git", "revert"]] == [], "取り消しを繰り返している" + assert [c for c in calls if c[:2] == ["git", "cherry-pick"]] == [] + assert [c for c in pushes if c[:2] == ["git", "push"]], "push を再送していない" + entry = read_state(state_path)["rounds"][0] + assert entry["pending_push"] is False + assert entry["apply"]["applied"] == ["R1-001"] diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index ed51c060..a77a6767 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1309,9 +1309,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") applied = list(entry["apply"]["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(path, state, entry, failed) else: - if failed: - applied = _apply_drop(path, state, entry, failed) entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) @@ -1326,10 +1327,18 @@ def _apply_drop( ) -> list[str]: """検証に失敗した項目を取り消し、採用として残る項目 ID を返す。 - **中断しても再開できる形で記録する。** `pending_drop` を立ててから取り消しへ入り、 - push まで終わってから消す。取り消しに失敗して中断すると印が残るので、次の実行は - 処理済みの判定より先にここへ戻ってくる。印を立てずに `merged_at` を先に立てると、 - 次の実行は素通りして**取り消しを再試行できない**。 + **中断しても再開できる形で記録する。** 失敗の位置で必要な再開が変わるため、 + 印は次の順で切り替える。 + + | 中断した位置 | 残る印 | 次の実行がすること | + | --- | --- | --- | + | 取り消しの途中 | `pending_drop` あり / `merged_at` なし | 取り消しをやり直す | + | 取り消し後・push 前 | `pending_drop` なし / `merged_at` あり / `pending_push` あり | **push の再送だけ** | + + 取り消しより先に `merged_at` を立てると、取り消しに失敗したときに次の実行が + 処理済みガードで素通りし、**再試行できない**。逆に push まで終えるまで + `merged_at` を立てないと、push だけ失敗したときに次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 """ work = state["worktrees"]["work"] entry["pending_drop"] = list(failed) @@ -1357,7 +1366,11 @@ def _apply_drop( state["phase"] = "propose" entry["pending_drop"] = [] - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 + # **取り消しが済んだことを push より先に永続化する。** 保存せずに push して + # 失敗すると、次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを + # 「未割当」と判定してラウンドごと巻き込んでしまう。`pending_push` は残るので + # 次の実行は push の再送だけを行う。 + entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) _push_head(state) entry["pending_push"] = False @@ -1376,8 +1389,6 @@ def _resume_incomplete_apply( if entry.get("pending_drop"): info("↻ 前回終わらなかった取り消しを再実行します") _apply_drop(path, state, entry, list(entry["pending_drop"])) - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) return _flush_pending_push(path, state, entry) @@ -2373,12 +2384,15 @@ def _drop_items( "reverted": len(ordered), "replayed": len(replay)} _revert_range(work, ordered, head) + # 取り消しが済んだ地点。積み直しに失敗したらここへ戻せばよい。 + reverted_head = _git_out(work, ["rev-parse", "HEAD"]) mapping = _replay_commits(work, replay) mode = "item" if mapping is None: info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") - _reset_hard(work, head) - _revert_range(work, ordered, head) + # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが + # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 + _reset_hard(work, reverted_head) mapping, mode = {}, "round" dropped = list(entry["items"]) if mode == "round" else pending diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_drop_items_git.py index bf828a03..de3759e5 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_drop_items_git.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_drop_items_git.py @@ -189,12 +189,18 @@ def test_adjacent_changes_fall_back_to_the_whole_round(refactor, adjacent_repo): 半端な履歴を残すより、決定的な状態へ落とす方が安全である。 """ state, entry = _state(adjacent_repo) + before = int(_git("rev-list", "--count", "HEAD", + cwd=adjacent_repo["repo"]).stdout.strip()) result = refactor._drop_items(state, entry, ["R1-001"]) assert result["mode"] == "round" assert _content(adjacent_repo) == "".join(LINES), "着手前の内容へ戻っていない" assert all(i["reverted"] for i in state["items"]) assert entry["drops"][-1]["mode"] == "round" + # 取り消しは 1 組だけ。着手前まで戻してやり直すと 2 組できて履歴が汚れる + after = int(_git("rev-list", "--count", "HEAD", + cwd=adjacent_repo["repo"]).stdout.strip()) + assert after - before == 2, f"取り消しコミットが余分にある({after - before} 件)" def test_dropping_every_item_returns_to_the_base_tree(refactor, distant_repo): diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py index c7960120..f8dd5d56 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py @@ -391,12 +391,15 @@ def _drop_env(refactor, monkeypatch, revert_rc=0, pick_rc=0): """ calls: list[list[str]] = [] picked: list[str] = [] + reverted: list[str] = [] def fake_run(cmd, **kwargs): calls.append(list(cmd)) rc = 0 if cmd[:2] == ["git", "revert"] and "--abort" not in cmd: rc = revert_rc + if rc == 0: + reverted.append(cmd[-1]) if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: rc = pick_rc if rc == 0: @@ -407,7 +410,9 @@ def fake_git_out(work, args): if args[:2] == ["rev-parse", "--verify"]: return args[-1].replace("^{commit}", "") if args == ["rev-parse", "HEAD"]: - return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + if picked: + return f"new-{picked[-1]}" + return "REVERTED_HEAD" if reverted else "HEAD_BEFORE" return "HEAD_BEFORE" monkeypatch.setattr(refactor.subprocess, "run", fake_run) @@ -491,7 +496,11 @@ def test_replay_conflict_falls_back_to_whole_round( assert e.value.code == 2, "全件失敗として次の提案ラウンドへ進むこと" assert ["git", "cherry-pick", "--abort"] in calls - assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + # 取り消しが済んだ地点へ戻すだけ。着手前まで戻して取り消しをやり直すと、 + # 同じ範囲の revert コミットが 2 組できて履歴が汚れる + assert ["git", "reset", "--hard", "REVERTED_HEAD"] in calls + reverts = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + assert reverts == ["bad222", "bad111", "ok111"], "取り消しを 2 度走らせている" state = read_state(state_path) assert all(i["status"] == "abandoned" for i in state["items"]) assert state["rounds"][0]["apply"]["applied"] == [] @@ -1139,3 +1148,40 @@ def test_push_precedes_nothing_when_the_drop_is_unfinished( assert "push" in order assert order.index("revert") < order.index("push"), "取り消しより先に push している" assert read_state(state_path)["rounds"][0]["apply"]["merged_at"] is not None + + +def test_push_failure_after_a_successful_drop_only_retries_the_push( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しが済んだあとに push だけ失敗したら、次は push の再送だけを行うこと。 + + 取り消しの完了を push より先に永続化しないと、次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch) + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: (_ for _ in ()).throw(SystemExit(4)) + if cmd[:2] == ["git", "push"] else "", + ) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + entry = read_state(state_path)["rounds"][0] + assert entry["pending_drop"] == [], "取り消しは済んでいるのに再実行の対象が残っている" + assert entry["apply"]["merged_at"] is not None, "取り消しの完了が保存されていない" + assert entry["pending_push"] is True + + # 2 回目: push が通る。取り消しは繰り返さない + calls, pushes = _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert [c for c in calls if c[:2] == ["git", "revert"]] == [], "取り消しを繰り返している" + assert [c for c in calls if c[:2] == ["git", "cherry-pick"]] == [] + assert [c for c in pushes if c[:2] == ["git", "push"]], "push を再送していない" + entry = read_state(state_path)["rounds"][0] + assert entry["pending_push"] is False + assert entry["apply"]["applied"] == ["R1-001"] diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ed51c060..a77a6767 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1309,9 +1309,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _drop_items(state, entry, failed, dry_run=True) info("(dry-run)状態ファイルは更新していません") applied = list(entry["apply"]["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(path, state, entry, failed) else: - if failed: - applied = _apply_drop(path, state, entry, failed) entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) @@ -1326,10 +1327,18 @@ def _apply_drop( ) -> list[str]: """検証に失敗した項目を取り消し、採用として残る項目 ID を返す。 - **中断しても再開できる形で記録する。** `pending_drop` を立ててから取り消しへ入り、 - push まで終わってから消す。取り消しに失敗して中断すると印が残るので、次の実行は - 処理済みの判定より先にここへ戻ってくる。印を立てずに `merged_at` を先に立てると、 - 次の実行は素通りして**取り消しを再試行できない**。 + **中断しても再開できる形で記録する。** 失敗の位置で必要な再開が変わるため、 + 印は次の順で切り替える。 + + | 中断した位置 | 残る印 | 次の実行がすること | + | --- | --- | --- | + | 取り消しの途中 | `pending_drop` あり / `merged_at` なし | 取り消しをやり直す | + | 取り消し後・push 前 | `pending_drop` なし / `merged_at` あり / `pending_push` あり | **push の再送だけ** | + + 取り消しより先に `merged_at` を立てると、取り消しに失敗したときに次の実行が + 処理済みガードで素通りし、**再試行できない**。逆に push まで終えるまで + `merged_at` を立てないと、push だけ失敗したときに次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 """ work = state["worktrees"]["work"] entry["pending_drop"] = list(failed) @@ -1357,7 +1366,11 @@ def _apply_drop( state["phase"] = "propose" entry["pending_drop"] = [] - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 + # **取り消しが済んだことを push より先に永続化する。** 保存せずに push して + # 失敗すると、次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを + # 「未割当」と判定してラウンドごと巻き込んでしまう。`pending_push` は残るので + # 次の実行は push の再送だけを行う。 + entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) _push_head(state) entry["pending_push"] = False @@ -1376,8 +1389,6 @@ def _resume_incomplete_apply( if entry.get("pending_drop"): info("↻ 前回終わらなかった取り消しを再実行します") _apply_drop(path, state, entry, list(entry["pending_drop"])) - entry["apply"]["merged_at"] = statefile.now() - statefile.save(path, state) return _flush_pending_push(path, state, entry) @@ -2373,12 +2384,15 @@ def _drop_items( "reverted": len(ordered), "replayed": len(replay)} _revert_range(work, ordered, head) + # 取り消しが済んだ地点。積み直しに失敗したらここへ戻せばよい。 + reverted_head = _git_out(work, ["rev-parse", "HEAD"]) mapping = _replay_commits(work, replay) mode = "item" if mapping is None: info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") - _reset_hard(work, head) - _revert_range(work, ordered, head) + # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが + # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 + _reset_hard(work, reverted_head) mapping, mode = {}, "round" dropped = list(entry["items"]) if mode == "round" else pending diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_drop_items_git.py index bf828a03..de3759e5 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_drop_items_git.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_drop_items_git.py @@ -189,12 +189,18 @@ def test_adjacent_changes_fall_back_to_the_whole_round(refactor, adjacent_repo): 半端な履歴を残すより、決定的な状態へ落とす方が安全である。 """ state, entry = _state(adjacent_repo) + before = int(_git("rev-list", "--count", "HEAD", + cwd=adjacent_repo["repo"]).stdout.strip()) result = refactor._drop_items(state, entry, ["R1-001"]) assert result["mode"] == "round" assert _content(adjacent_repo) == "".join(LINES), "着手前の内容へ戻っていない" assert all(i["reverted"] for i in state["items"]) assert entry["drops"][-1]["mode"] == "round" + # 取り消しは 1 組だけ。着手前まで戻してやり直すと 2 組できて履歴が汚れる + after = int(_git("rev-list", "--count", "HEAD", + cwd=adjacent_repo["repo"]).stdout.strip()) + assert after - before == 2, f"取り消しコミットが余分にある({after - before} 件)" def test_dropping_every_item_returns_to_the_base_tree(refactor, distant_repo): diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py index c7960120..f8dd5d56 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py @@ -391,12 +391,15 @@ def _drop_env(refactor, monkeypatch, revert_rc=0, pick_rc=0): """ calls: list[list[str]] = [] picked: list[str] = [] + reverted: list[str] = [] def fake_run(cmd, **kwargs): calls.append(list(cmd)) rc = 0 if cmd[:2] == ["git", "revert"] and "--abort" not in cmd: rc = revert_rc + if rc == 0: + reverted.append(cmd[-1]) if cmd[:2] == ["git", "cherry-pick"] and "--abort" not in cmd: rc = pick_rc if rc == 0: @@ -407,7 +410,9 @@ def fake_git_out(work, args): if args[:2] == ["rev-parse", "--verify"]: return args[-1].replace("^{commit}", "") if args == ["rev-parse", "HEAD"]: - return f"new-{picked[-1]}" if picked else "HEAD_BEFORE" + if picked: + return f"new-{picked[-1]}" + return "REVERTED_HEAD" if reverted else "HEAD_BEFORE" return "HEAD_BEFORE" monkeypatch.setattr(refactor.subprocess, "run", fake_run) @@ -491,7 +496,11 @@ def test_replay_conflict_falls_back_to_whole_round( assert e.value.code == 2, "全件失敗として次の提案ラウンドへ進むこと" assert ["git", "cherry-pick", "--abort"] in calls - assert ["git", "reset", "--hard", "HEAD_BEFORE"] in calls + # 取り消しが済んだ地点へ戻すだけ。着手前まで戻して取り消しをやり直すと、 + # 同じ範囲の revert コミットが 2 組できて履歴が汚れる + assert ["git", "reset", "--hard", "REVERTED_HEAD"] in calls + reverts = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + assert reverts == ["bad222", "bad111", "ok111"], "取り消しを 2 度走らせている" state = read_state(state_path) assert all(i["status"] == "abandoned" for i in state["items"]) assert state["rounds"][0]["apply"]["applied"] == [] @@ -1139,3 +1148,40 @@ def test_push_precedes_nothing_when_the_drop_is_unfinished( assert "push" in order assert order.index("revert") < order.index("push"), "取り消しより先に push している" assert read_state(state_path)["rounds"][0]["apply"]["merged_at"] is not None + + +def test_push_failure_after_a_successful_drop_only_retries_the_push( + refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts +): + """取り消しが済んだあとに push だけ失敗したら、次は push の再送だけを行うこと。 + + 取り消しの完了を push より先に永続化しないと、次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 + """ + state_path = _two_item_apply(tmp_path, env_tmp_dir, git_facts) + _drop_env(refactor, monkeypatch) + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: (_ for _ in ()).throw(SystemExit(4)) + if cmd[:2] == ["git", "push"] else "", + ) + with pytest.raises(SystemExit): + refactor.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})() + ) + + entry = read_state(state_path)["rounds"][0] + assert entry["pending_drop"] == [], "取り消しは済んでいるのに再実行の対象が残っている" + assert entry["apply"]["merged_at"] is not None, "取り消しの完了が保存されていない" + assert entry["pending_push"] is True + + # 2 回目: push が通る。取り消しは繰り返さない + calls, pushes = _drop_env(refactor, monkeypatch) + refactor.cmd_merge_apply(type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + + assert [c for c in calls if c[:2] == ["git", "revert"]] == [], "取り消しを繰り返している" + assert [c for c in calls if c[:2] == ["git", "cherry-pick"]] == [] + assert [c for c in pushes if c[:2] == ["git", "push"]], "push を再送していない" + entry = read_state(state_path)["rounds"][0] + assert entry["pending_push"] is False + assert entry["apply"]["applied"] == ["R1-001"] From 48a93ba1c8baf804fb368c9b579e3d1d92be7b0a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 03:47:11 +0000 Subject: [PATCH 4/6] =?UTF-8?q?Docs:=20=E5=AE=9F=E6=96=BD=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=81=AE=E7=8A=B6=E6=85=8B=E3=82=B9=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E8=A1=A8=E8=A8=98=E3=82=92=E5=AE=9F=E8=A3=85=E3=81=B8?= =?UTF-8?q?=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `rounds[].apply.progress` は実装の `rounds[].apply_progress` と食い違っていた。 あわせて退避先(取り消しが済んだ地点)と、`pending_drop` / `merged_at` / `rf_eval` の記述も実装へ追従させる。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GSwBvT9CH8mKfgyFn2JWfS --- .../issue-113-cross-refactoring-defect-fixes.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/issues/issue-113-cross-refactoring-defect-fixes.md b/issues/issue-113-cross-refactoring-defect-fixes.md index 0600c6d8..f033f2a6 100644 --- a/issues/issue-113-cross-refactoring-defect-fixes.md +++ b/issues/issue-113-cross-refactoring-defect-fixes.md @@ -41,12 +41,12 @@ - [ ] 1. 取り消し対象より新しい別項目のコミットがあっても、**変更が独立していれば** 項目単位で取り消せる(`test_drop_older_item_keeps_the_newer_one`) -- [ ] 2. 積み直しが競合したときは、着手前の状態まで戻してラウンド全件を取り消し、 - 半端な履歴を残さない(`test_adjacent_changes_fall_back_to_the_whole_round`) +- [ ] 2. 積み直しが競合したときは、取り消しが済んだ地点へ戻してラウンド全件を + 取り消し、半端な履歴を残さない(`test_adjacent_changes_fall_back_to_the_whole_round`) - [ ] 3. 取り消しに失敗したら終了コード 4 で中断する。進行スクリプトは 終了コード 2(全件失敗)と 4(中断)を区別する - [ ] 4. 検証の途中で中断しても、そこまでの判定が状態ファイルへ残る - (`items[].status` と `rounds[].apply.progress`) + (`items[].status` と `rounds[].apply_progress`) - [ ] 5. 取り消しの push が完了するまで `pending_push` が立ち、 次の実行が処理済み判定より先に再送信する(取り消し着手**前**に立てる) - [ ] 6. `target_scope` の外を触ったコミットを含む項目は失敗になる @@ -117,7 +117,7 @@ | --- | --- | --- | | `refactor.py` の終了コード | 中断を 4 として追加 | 追加のみ。0 / 2 / 3 の意味は変えない | | 提案の結果ファイル名 | `-r<ラウンド>` を追加 | 破る。進行スクリプトと `--stem-template` を同時に変更する | -| 状態ファイル | `vocabulary` / `auth` / `apply.progress` を追加 | 追加のみ。欠けていても読める | +| 状態ファイル | `vocabulary` / `auth` / `apply_progress` / `drops` / `pending_drop` を追加 | 追加のみ。欠けていても読める | | `--scope` の意味 | 検証にも使う | 破る(これまで検証に反映されていなかった)。手順書に明記する | ## 修正対象 @@ -143,7 +143,7 @@ plugins/ndf-{claude,codex,kiro}/skills/... # 配布物(生成) - **対象ファイル:** `scripts/refactor.py`、`tests/test_abandon_items.py`、`tests/test_merge_apply.py` - **変更内容:** `_drop_items()` を追加する。範囲を新しい順に全て `git revert` し、 残す項目のコミットを古い順に `git cherry-pick` で積み直す。積み直しが競合したら - 着手前 HEAD へ戻し、ラウンド全件の取り消しへ退避する。 + 取り消しが済んだ地点へ戻し、ラウンド全件の取り消しへ退避する。 `cmd_merge_apply` と `cmd_abandon_items` を `_drop_items()` 経由に置き換える - **満たす受け入れ条件:** 1, 2 - **進め方:** 競合する履歴を模す失敗テスト → 実装 → 既存の取り消しテストを新形へ移す @@ -153,7 +153,10 @@ plugins/ndf-{claude,codex,kiro}/skills/... # 配布物(生成) - **対象ファイル:** `scripts/refactor.py`、`SKILL.md`、`docs/02-apply-and-review.md` - **変更内容:** 中断を終了コード 4 に統一する(`die` の既定値)。`cmd_merge_apply` は 項目ごとの判定を**その都度**状態ファイルへ保存する。取り消しへ着手する**前**に - `pending_push` を立てる。進行スクリプトは 2 と 4 を区別し、4 では `exit` する + `pending_drop` と `pending_push` を立て、取り消しが済んだ時点で `apply.merged_at` を + 立ててから push する。進行スクリプトは 2 と 4 を区別し、4 では `exit` する + (出力を `eval` する呼び出しは `rf_eval` を使う。コマンド置換のサブシェルでは + `exit` が親へ伝わらない) - **満たす受け入れ条件:** 3, 4, 5 - **進め方:** 中断時の状態を確かめる失敗テスト → 実装 → 手順書の更新 From 16bd71fddb902501214fa7820fd161c0c09e2926 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 03:53:41 +0000 Subject: [PATCH 5/6] =?UTF-8?q?Fix:=20=E7=AF=84=E5=9B=B2=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E3=81=AE=20`./`=20=E3=82=92=E6=AD=A3=E8=A6=8F=E5=8C=96?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--scope ./src` はシェル補完で頻出するが、git が出すのは `src/foo.py` なので そのまま比べると全てのコミットが範囲外になり、適用が必ず失敗していた。 `./` を落としてから突き合わせ、`.` と `./` はリポジトリ全体として扱う。 空の指定は全許可にせず無視する(書き損じで検査が骨抜きにならないように)。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GSwBvT9CH8mKfgyFn2JWfS --- .../cross-refactoring/scripts/refactor.py | 14 +++++++++++-- .../tests/test_merge_apply.py | 20 +++++++++++++++++++ .../cross-refactoring/scripts/refactor.py | 14 +++++++++++-- .../tests/test_merge_apply.py | 20 +++++++++++++++++++ .../cross-refactoring/scripts/refactor.py | 14 +++++++++++-- .../tests/test_merge_apply.py | 20 +++++++++++++++++++ .../cross-refactoring/scripts/refactor.py | 14 +++++++++++-- .../tests/test_merge_apply.py | 20 +++++++++++++++++++ 8 files changed, 128 insertions(+), 8 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index a77a6767..b38b0368 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -423,11 +423,21 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: 除外規則を足さない。規則を書けるようにすると、規則を 1 行足すだけで 範囲の検査を骨抜きにできてしまう。 + + 突き合わせる前に `./` を落とす。シェルの補完で `--scope ./src` の形になることが + 多い一方、git が出すのは `src/foo.py` なので、**そのまま比べると全てのコミットが + 範囲外**になり、適用が必ず失敗する。`.` と `./` はリポジトリ全体を指す。 """ for entry in scope: - prefix = str(entry).strip().rstrip("/") - if not prefix: + raw = str(entry).strip() + if not raw: continue + prefix = raw + while prefix.startswith("./"): + prefix = prefix[2:] + prefix = prefix.rstrip("/") + if prefix in {"", "."}: + return True if path == prefix or path.startswith(prefix + "/"): return True return False diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py index f8dd5d56..6b772a0c 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_merge_apply.py @@ -627,6 +627,26 @@ def test_scope_check_matches_only_on_path_prefix(refactor): assert refactor.out_of_scope_files({"files": ["any.py"]}, []) == [] +@pytest.mark.parametrize("scope", [["./src"], ["src/"], ["./src/"], [" ./src "]]) +def test_scope_accepts_shell_completed_paths(refactor, scope): + """`--scope ./src` は補完で頻出する。git は `src/foo.py` と出すので正規化する。 + + 正規化しないと**全てのコミットが範囲外**になり、適用が必ず失敗する。 + """ + assert refactor.path_in_scope("src/foo.py", scope) + assert not refactor.path_in_scope("dist/foo.py", scope) + + +@pytest.mark.parametrize("scope", [["."], ["./"], [".//"]]) +def test_scope_dot_means_the_whole_repository(refactor, scope): + assert refactor.path_in_scope("anywhere/deep/foo.py", scope) + + +def test_blank_scope_entry_is_ignored(refactor): + """空の指定で全許可にしない。指定の書き損じで検査が骨抜きになる。""" + assert not refactor.path_in_scope("dist/foo.py", ["", " ", "src"]) + + def test_no_push_when_nothing_was_reverted( refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts ): diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index a77a6767..b38b0368 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -423,11 +423,21 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: 除外規則を足さない。規則を書けるようにすると、規則を 1 行足すだけで 範囲の検査を骨抜きにできてしまう。 + + 突き合わせる前に `./` を落とす。シェルの補完で `--scope ./src` の形になることが + 多い一方、git が出すのは `src/foo.py` なので、**そのまま比べると全てのコミットが + 範囲外**になり、適用が必ず失敗する。`.` と `./` はリポジトリ全体を指す。 """ for entry in scope: - prefix = str(entry).strip().rstrip("/") - if not prefix: + raw = str(entry).strip() + if not raw: continue + prefix = raw + while prefix.startswith("./"): + prefix = prefix[2:] + prefix = prefix.rstrip("/") + if prefix in {"", "."}: + return True if path == prefix or path.startswith(prefix + "/"): return True return False diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py index f8dd5d56..6b772a0c 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_merge_apply.py @@ -627,6 +627,26 @@ def test_scope_check_matches_only_on_path_prefix(refactor): assert refactor.out_of_scope_files({"files": ["any.py"]}, []) == [] +@pytest.mark.parametrize("scope", [["./src"], ["src/"], ["./src/"], [" ./src "]]) +def test_scope_accepts_shell_completed_paths(refactor, scope): + """`--scope ./src` は補完で頻出する。git は `src/foo.py` と出すので正規化する。 + + 正規化しないと**全てのコミットが範囲外**になり、適用が必ず失敗する。 + """ + assert refactor.path_in_scope("src/foo.py", scope) + assert not refactor.path_in_scope("dist/foo.py", scope) + + +@pytest.mark.parametrize("scope", [["."], ["./"], [".//"]]) +def test_scope_dot_means_the_whole_repository(refactor, scope): + assert refactor.path_in_scope("anywhere/deep/foo.py", scope) + + +def test_blank_scope_entry_is_ignored(refactor): + """空の指定で全許可にしない。指定の書き損じで検査が骨抜きになる。""" + assert not refactor.path_in_scope("dist/foo.py", ["", " ", "src"]) + + def test_no_push_when_nothing_was_reverted( refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts ): diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index a77a6767..b38b0368 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -423,11 +423,21 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: 除外規則を足さない。規則を書けるようにすると、規則を 1 行足すだけで 範囲の検査を骨抜きにできてしまう。 + + 突き合わせる前に `./` を落とす。シェルの補完で `--scope ./src` の形になることが + 多い一方、git が出すのは `src/foo.py` なので、**そのまま比べると全てのコミットが + 範囲外**になり、適用が必ず失敗する。`.` と `./` はリポジトリ全体を指す。 """ for entry in scope: - prefix = str(entry).strip().rstrip("/") - if not prefix: + raw = str(entry).strip() + if not raw: continue + prefix = raw + while prefix.startswith("./"): + prefix = prefix[2:] + prefix = prefix.rstrip("/") + if prefix in {"", "."}: + return True if path == prefix or path.startswith(prefix + "/"): return True return False diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py index f8dd5d56..6b772a0c 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_merge_apply.py @@ -627,6 +627,26 @@ def test_scope_check_matches_only_on_path_prefix(refactor): assert refactor.out_of_scope_files({"files": ["any.py"]}, []) == [] +@pytest.mark.parametrize("scope", [["./src"], ["src/"], ["./src/"], [" ./src "]]) +def test_scope_accepts_shell_completed_paths(refactor, scope): + """`--scope ./src` は補完で頻出する。git は `src/foo.py` と出すので正規化する。 + + 正規化しないと**全てのコミットが範囲外**になり、適用が必ず失敗する。 + """ + assert refactor.path_in_scope("src/foo.py", scope) + assert not refactor.path_in_scope("dist/foo.py", scope) + + +@pytest.mark.parametrize("scope", [["."], ["./"], [".//"]]) +def test_scope_dot_means_the_whole_repository(refactor, scope): + assert refactor.path_in_scope("anywhere/deep/foo.py", scope) + + +def test_blank_scope_entry_is_ignored(refactor): + """空の指定で全許可にしない。指定の書き損じで検査が骨抜きになる。""" + assert not refactor.path_in_scope("dist/foo.py", ["", " ", "src"]) + + def test_no_push_when_nothing_was_reverted( refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts ): diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index a77a6767..b38b0368 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -423,11 +423,21 @@ def path_in_scope(path: str, scope: Iterable[str]) -> bool: 除外規則を足さない。規則を書けるようにすると、規則を 1 行足すだけで 範囲の検査を骨抜きにできてしまう。 + + 突き合わせる前に `./` を落とす。シェルの補完で `--scope ./src` の形になることが + 多い一方、git が出すのは `src/foo.py` なので、**そのまま比べると全てのコミットが + 範囲外**になり、適用が必ず失敗する。`.` と `./` はリポジトリ全体を指す。 """ for entry in scope: - prefix = str(entry).strip().rstrip("/") - if not prefix: + raw = str(entry).strip() + if not raw: continue + prefix = raw + while prefix.startswith("./"): + prefix = prefix[2:] + prefix = prefix.rstrip("/") + if prefix in {"", "."}: + return True if path == prefix or path.startswith(prefix + "/"): return True return False diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py index f8dd5d56..6b772a0c 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_merge_apply.py @@ -627,6 +627,26 @@ def test_scope_check_matches_only_on_path_prefix(refactor): assert refactor.out_of_scope_files({"files": ["any.py"]}, []) == [] +@pytest.mark.parametrize("scope", [["./src"], ["src/"], ["./src/"], [" ./src "]]) +def test_scope_accepts_shell_completed_paths(refactor, scope): + """`--scope ./src` は補完で頻出する。git は `src/foo.py` と出すので正規化する。 + + 正規化しないと**全てのコミットが範囲外**になり、適用が必ず失敗する。 + """ + assert refactor.path_in_scope("src/foo.py", scope) + assert not refactor.path_in_scope("dist/foo.py", scope) + + +@pytest.mark.parametrize("scope", [["."], ["./"], [".//"]]) +def test_scope_dot_means_the_whole_repository(refactor, scope): + assert refactor.path_in_scope("anywhere/deep/foo.py", scope) + + +def test_blank_scope_entry_is_ignored(refactor): + """空の指定で全許可にしない。指定の書き損じで検査が骨抜きになる。""" + assert not refactor.path_in_scope("dist/foo.py", ["", " ", "src"]) + + def test_no_push_when_nothing_was_reverted( refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts ): From 833755574830cd12f8cad5a0517e7ae2b483d044 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 16 Aug 2026 04:03:07 +0000 Subject: [PATCH 6/6] =?UTF-8?q?Fix:=20=E8=A6=8B=E9=80=81=E3=82=8A=E3=81=A8?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E3=81=AE=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=A7=E3=82=82=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=97=E3=81=AE?= =?UTF-8?q?=E5=86=8D=E9=96=8B=E7=8A=B6=E6=85=8B=E3=82=92=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_run_drop()` を切り出し、`pending_drop` を立てて取り消しへ入り、戻ったら すぐ保存する。印は呼び出し側が完了の記録と同じ保存で消す - `abandon-items` はやり残した取り消しを push の再送より先に片づける。 先に push すると取り消しが途中の HEAD を公開してしまう - `merge-fix` は取り消し後の起点をその場で保存する。保存前に落ちると、 次の実行が取り消しコミット自体を未申告と判定して取り消しを取り消していた Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01GSwBvT9CH8mKfgyFn2JWfS --- .../cross-refactoring/scripts/refactor.py | 61 +++++++++++++------ .../tests/test_abandon_items.py | 56 +++++++++++++++++ .../cross-refactoring/scripts/refactor.py | 61 +++++++++++++------ .../tests/test_abandon_items.py | 56 +++++++++++++++++ .../cross-refactoring/scripts/refactor.py | 61 +++++++++++++------ .../tests/test_abandon_items.py | 56 +++++++++++++++++ .../cross-refactoring/scripts/refactor.py | 61 +++++++++++++------ .../tests/test_abandon_items.py | 56 +++++++++++++++++ 8 files changed, 392 insertions(+), 76 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index b38b0368..3ad3134b 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -1331,6 +1331,28 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _run_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + targets: list[str], +) -> dict[str, Any]: + """取り消しを、中断しても再開できる形で実行する。 + + `pending_drop` と `pending_push` を立ててから入り、**戻ったらすぐ保存する**。 + 保存しないまま落ちると、積み直しで変わった SHA と取り消し済みの印が失われ、 + 次の実行は**履歴に無い SHA を相手に**取り消しをやり直すことになる。 + + 印はここでは消さない。**呼び出し側が完了の記録と同じ保存で消す。** 先に消すと、 + 完了を記録する前に落ちたときに、次の実行が「取り消し済みだが未完了」の状態を + 見分けられなくなる。 + """ + entry["pending_drop"] = list(targets) + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, list(targets)) + statefile.save(path, state) + return result + + def _apply_drop( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], failed: list[str], @@ -1351,13 +1373,7 @@ def _apply_drop( 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 """ work = state["worktrees"]["work"] - entry["pending_drop"] = list(failed) - # 取り消しへ着手する**前に**再送信の印も立てる。取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - - result = _drop_items(state, entry, list(failed)) + result = _run_drop(path, state, entry, failed) applied = list(entry["apply"].get("applied") or []) if result["mode"] == "round": # 積み直せなかった。合意済みの項目も含めて全件捨てる。 @@ -1375,11 +1391,11 @@ def _apply_drop( entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) state["phase"] = "propose" + # **取り消しが済んだことを push より先に、印の解除と同じ保存で永続化する。** + # 保存せずに push して失敗すると、次の実行が適用の検証をやり直し、取り消しと + # 積み直しのコミットを「未割当」と判定してラウンドごと巻き込んでしまう。 + # `pending_push` は残るので、次の実行は push の再送だけを行う。 entry["pending_drop"] = [] - # **取り消しが済んだことを push より先に永続化する。** 保存せずに push して - # 失敗すると、次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを - # 「未割当」と判定してラウンドごと巻き込んでしまう。`pending_push` は残るので - # 次の実行は push の再送だけを行う。 entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) _push_head(state) @@ -1547,7 +1563,13 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: path, state = _load(args.id) entry = _round(state, args.round) if not args.dry_run: - _flush_pending_push(path, state, entry) + # **やり残した取り消しを push の再送より先に片づける。** 先に push すると、 + # 取り消しが途中の HEAD をそのまま Pull Request へ反映してしまう。 + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + _run_drop(path, state, entry, list(entry["pending_drop"])) + else: + _flush_pending_push(path, state, entry) # 取り消し自体は `reverted` で冪等だが、見送りの記録は重複しうる。 if entry.get("abandoned") is not None: @@ -1573,11 +1595,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: info("(dry-run)状態ファイルは更新していません") return - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、Pull Request 側には未検証の差分が残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - result = _drop_items(state, entry, targets) + result = _run_drop(path, state, entry, targets) if result["mode"] == "round": info("積み直せなかったため、このラウンドで適用した項目を全件見送ります") targets = list(entry["apply"].get("applied") or targets) @@ -1597,9 +1615,11 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: }) info(f"↩ {item_id} を見送りました") + # 見送りの記録と印の解除を**同じ保存で**行う。保存してから push するので、 + # push が失敗しても記録とローカルの git が食い違わない。 entry["abandoned"] = targets + entry["pending_drop"] = [] state["phase"] = "propose" - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) _push_head(state) entry["pending_push"] = False @@ -1735,8 +1755,11 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: "commits": list(ordered_range)}, dry_run=False, ) - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True diff --git a/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py index e5c46d59..63b2bb64 100644 --- a/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-claude/skills/cross-refactoring/tests/test_abandon_items.py @@ -870,3 +870,59 @@ def spying_run(cmd, **kwargs): assert marks and marks[0] is True assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +def test_abandon_saves_the_drop_result_before_pushing( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """取り消しの結果を push より先に保存すること。 + + 保存しないまま落ちると、積み直しで変わった SHA と取り消し済みの印が失われ、 + 次の実行が**履歴に無い SHA を相手に**取り消しをやり直す。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + seen: list[dict] = [] + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: seen.append(read_state(state_path)) or "", + ) + refactor.cmd_abandon_items(_args()) + + assert seen, "push が実行されていない" + at_push = seen[0] + by_id = {i["item_id"]: i for i in at_push["items"]} + assert by_id["R1-002"]["commits"] == ["new-sha-R1-002"], "SHA の追従が保存前" + assert by_id["R1-001"]["reverted"] is True + assert at_push["rounds"][0]["pending_drop"] == [] + + +def test_abandon_retries_the_drop_before_resending_the_push( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """やり残した取り消しは、push の再送より先に片づけること。 + + 先に push すると、取り消しが途中の HEAD をそのまま公開してしまう。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + state = read_state(state_path) + state["rounds"][0]["pending_drop"] = ["R1-001"] + state["rounds"][0]["pending_push"] = True + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + env_tmp_dir(state_path) + + order: list[str] = [] + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + real_run = refactor.subprocess.run + monkeypatch.setattr( + refactor.subprocess, "run", + lambda cmd, **kw: (order.append(cmd[1]) if cmd[:1] == ["git"] else None) + or real_run(cmd, **kw), + ) + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: order.append("push") or "") + refactor.cmd_abandon_items(_args()) + + assert "revert" in order and "push" in order + assert order.index("revert") < order.index("push"), "取り消しより先に push している" + assert read_state(state_path)["rounds"][0]["pending_push"] is False diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index b38b0368..3ad3134b 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -1331,6 +1331,28 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _run_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + targets: list[str], +) -> dict[str, Any]: + """取り消しを、中断しても再開できる形で実行する。 + + `pending_drop` と `pending_push` を立ててから入り、**戻ったらすぐ保存する**。 + 保存しないまま落ちると、積み直しで変わった SHA と取り消し済みの印が失われ、 + 次の実行は**履歴に無い SHA を相手に**取り消しをやり直すことになる。 + + 印はここでは消さない。**呼び出し側が完了の記録と同じ保存で消す。** 先に消すと、 + 完了を記録する前に落ちたときに、次の実行が「取り消し済みだが未完了」の状態を + 見分けられなくなる。 + """ + entry["pending_drop"] = list(targets) + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, list(targets)) + statefile.save(path, state) + return result + + def _apply_drop( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], failed: list[str], @@ -1351,13 +1373,7 @@ def _apply_drop( 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 """ work = state["worktrees"]["work"] - entry["pending_drop"] = list(failed) - # 取り消しへ着手する**前に**再送信の印も立てる。取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - - result = _drop_items(state, entry, list(failed)) + result = _run_drop(path, state, entry, failed) applied = list(entry["apply"].get("applied") or []) if result["mode"] == "round": # 積み直せなかった。合意済みの項目も含めて全件捨てる。 @@ -1375,11 +1391,11 @@ def _apply_drop( entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) state["phase"] = "propose" + # **取り消しが済んだことを push より先に、印の解除と同じ保存で永続化する。** + # 保存せずに push して失敗すると、次の実行が適用の検証をやり直し、取り消しと + # 積み直しのコミットを「未割当」と判定してラウンドごと巻き込んでしまう。 + # `pending_push` は残るので、次の実行は push の再送だけを行う。 entry["pending_drop"] = [] - # **取り消しが済んだことを push より先に永続化する。** 保存せずに push して - # 失敗すると、次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを - # 「未割当」と判定してラウンドごと巻き込んでしまう。`pending_push` は残るので - # 次の実行は push の再送だけを行う。 entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) _push_head(state) @@ -1547,7 +1563,13 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: path, state = _load(args.id) entry = _round(state, args.round) if not args.dry_run: - _flush_pending_push(path, state, entry) + # **やり残した取り消しを push の再送より先に片づける。** 先に push すると、 + # 取り消しが途中の HEAD をそのまま Pull Request へ反映してしまう。 + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + _run_drop(path, state, entry, list(entry["pending_drop"])) + else: + _flush_pending_push(path, state, entry) # 取り消し自体は `reverted` で冪等だが、見送りの記録は重複しうる。 if entry.get("abandoned") is not None: @@ -1573,11 +1595,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: info("(dry-run)状態ファイルは更新していません") return - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、Pull Request 側には未検証の差分が残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - result = _drop_items(state, entry, targets) + result = _run_drop(path, state, entry, targets) if result["mode"] == "round": info("積み直せなかったため、このラウンドで適用した項目を全件見送ります") targets = list(entry["apply"].get("applied") or targets) @@ -1597,9 +1615,11 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: }) info(f"↩ {item_id} を見送りました") + # 見送りの記録と印の解除を**同じ保存で**行う。保存してから push するので、 + # push が失敗しても記録とローカルの git が食い違わない。 entry["abandoned"] = targets + entry["pending_drop"] = [] state["phase"] = "propose" - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) _push_head(state) entry["pending_push"] = False @@ -1735,8 +1755,11 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: "commits": list(ordered_range)}, dry_run=False, ) - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True diff --git a/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py index e5c46d59..63b2bb64 100644 --- a/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-codex/skills/cross-refactoring/tests/test_abandon_items.py @@ -870,3 +870,59 @@ def spying_run(cmd, **kwargs): assert marks and marks[0] is True assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +def test_abandon_saves_the_drop_result_before_pushing( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """取り消しの結果を push より先に保存すること。 + + 保存しないまま落ちると、積み直しで変わった SHA と取り消し済みの印が失われ、 + 次の実行が**履歴に無い SHA を相手に**取り消しをやり直す。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + seen: list[dict] = [] + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: seen.append(read_state(state_path)) or "", + ) + refactor.cmd_abandon_items(_args()) + + assert seen, "push が実行されていない" + at_push = seen[0] + by_id = {i["item_id"]: i for i in at_push["items"]} + assert by_id["R1-002"]["commits"] == ["new-sha-R1-002"], "SHA の追従が保存前" + assert by_id["R1-001"]["reverted"] is True + assert at_push["rounds"][0]["pending_drop"] == [] + + +def test_abandon_retries_the_drop_before_resending_the_push( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """やり残した取り消しは、push の再送より先に片づけること。 + + 先に push すると、取り消しが途中の HEAD をそのまま公開してしまう。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + state = read_state(state_path) + state["rounds"][0]["pending_drop"] = ["R1-001"] + state["rounds"][0]["pending_push"] = True + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + env_tmp_dir(state_path) + + order: list[str] = [] + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + real_run = refactor.subprocess.run + monkeypatch.setattr( + refactor.subprocess, "run", + lambda cmd, **kw: (order.append(cmd[1]) if cmd[:1] == ["git"] else None) + or real_run(cmd, **kw), + ) + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: order.append("push") or "") + refactor.cmd_abandon_items(_args()) + + assert "revert" in order and "push" in order + assert order.index("revert") < order.index("push"), "取り消しより先に push している" + assert read_state(state_path)["rounds"][0]["pending_push"] is False diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index b38b0368..3ad3134b 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -1331,6 +1331,28 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _run_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + targets: list[str], +) -> dict[str, Any]: + """取り消しを、中断しても再開できる形で実行する。 + + `pending_drop` と `pending_push` を立ててから入り、**戻ったらすぐ保存する**。 + 保存しないまま落ちると、積み直しで変わった SHA と取り消し済みの印が失われ、 + 次の実行は**履歴に無い SHA を相手に**取り消しをやり直すことになる。 + + 印はここでは消さない。**呼び出し側が完了の記録と同じ保存で消す。** 先に消すと、 + 完了を記録する前に落ちたときに、次の実行が「取り消し済みだが未完了」の状態を + 見分けられなくなる。 + """ + entry["pending_drop"] = list(targets) + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, list(targets)) + statefile.save(path, state) + return result + + def _apply_drop( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], failed: list[str], @@ -1351,13 +1373,7 @@ def _apply_drop( 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 """ work = state["worktrees"]["work"] - entry["pending_drop"] = list(failed) - # 取り消しへ着手する**前に**再送信の印も立てる。取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - - result = _drop_items(state, entry, list(failed)) + result = _run_drop(path, state, entry, failed) applied = list(entry["apply"].get("applied") or []) if result["mode"] == "round": # 積み直せなかった。合意済みの項目も含めて全件捨てる。 @@ -1375,11 +1391,11 @@ def _apply_drop( entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) state["phase"] = "propose" + # **取り消しが済んだことを push より先に、印の解除と同じ保存で永続化する。** + # 保存せずに push して失敗すると、次の実行が適用の検証をやり直し、取り消しと + # 積み直しのコミットを「未割当」と判定してラウンドごと巻き込んでしまう。 + # `pending_push` は残るので、次の実行は push の再送だけを行う。 entry["pending_drop"] = [] - # **取り消しが済んだことを push より先に永続化する。** 保存せずに push して - # 失敗すると、次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを - # 「未割当」と判定してラウンドごと巻き込んでしまう。`pending_push` は残るので - # 次の実行は push の再送だけを行う。 entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) _push_head(state) @@ -1547,7 +1563,13 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: path, state = _load(args.id) entry = _round(state, args.round) if not args.dry_run: - _flush_pending_push(path, state, entry) + # **やり残した取り消しを push の再送より先に片づける。** 先に push すると、 + # 取り消しが途中の HEAD をそのまま Pull Request へ反映してしまう。 + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + _run_drop(path, state, entry, list(entry["pending_drop"])) + else: + _flush_pending_push(path, state, entry) # 取り消し自体は `reverted` で冪等だが、見送りの記録は重複しうる。 if entry.get("abandoned") is not None: @@ -1573,11 +1595,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: info("(dry-run)状態ファイルは更新していません") return - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、Pull Request 側には未検証の差分が残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - result = _drop_items(state, entry, targets) + result = _run_drop(path, state, entry, targets) if result["mode"] == "round": info("積み直せなかったため、このラウンドで適用した項目を全件見送ります") targets = list(entry["apply"].get("applied") or targets) @@ -1597,9 +1615,11 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: }) info(f"↩ {item_id} を見送りました") + # 見送りの記録と印の解除を**同じ保存で**行う。保存してから push するので、 + # push が失敗しても記録とローカルの git が食い違わない。 entry["abandoned"] = targets + entry["pending_drop"] = [] state["phase"] = "propose" - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) _push_head(state) entry["pending_push"] = False @@ -1735,8 +1755,11 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: "commits": list(ordered_range)}, dry_run=False, ) - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True diff --git a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py index e5c46d59..63b2bb64 100644 --- a/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/tests/test_abandon_items.py @@ -870,3 +870,59 @@ def spying_run(cmd, **kwargs): assert marks and marks[0] is True assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +def test_abandon_saves_the_drop_result_before_pushing( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """取り消しの結果を push より先に保存すること。 + + 保存しないまま落ちると、積み直しで変わった SHA と取り消し済みの印が失われ、 + 次の実行が**履歴に無い SHA を相手に**取り消しをやり直す。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + seen: list[dict] = [] + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: seen.append(read_state(state_path)) or "", + ) + refactor.cmd_abandon_items(_args()) + + assert seen, "push が実行されていない" + at_push = seen[0] + by_id = {i["item_id"]: i for i in at_push["items"]} + assert by_id["R1-002"]["commits"] == ["new-sha-R1-002"], "SHA の追従が保存前" + assert by_id["R1-001"]["reverted"] is True + assert at_push["rounds"][0]["pending_drop"] == [] + + +def test_abandon_retries_the_drop_before_resending_the_push( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """やり残した取り消しは、push の再送より先に片づけること。 + + 先に push すると、取り消しが途中の HEAD をそのまま公開してしまう。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + state = read_state(state_path) + state["rounds"][0]["pending_drop"] = ["R1-001"] + state["rounds"][0]["pending_push"] = True + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + env_tmp_dir(state_path) + + order: list[str] = [] + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + real_run = refactor.subprocess.run + monkeypatch.setattr( + refactor.subprocess, "run", + lambda cmd, **kw: (order.append(cmd[1]) if cmd[:1] == ["git"] else None) + or real_run(cmd, **kw), + ) + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: order.append("push") or "") + refactor.cmd_abandon_items(_args()) + + assert "revert" in order and "push" in order + assert order.index("revert") < order.index("push"), "取り消しより先に push している" + assert read_state(state_path)["rounds"][0]["pending_push"] is False diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b38b0368..3ad3134b 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1331,6 +1331,28 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _run_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + targets: list[str], +) -> dict[str, Any]: + """取り消しを、中断しても再開できる形で実行する。 + + `pending_drop` と `pending_push` を立ててから入り、**戻ったらすぐ保存する**。 + 保存しないまま落ちると、積み直しで変わった SHA と取り消し済みの印が失われ、 + 次の実行は**履歴に無い SHA を相手に**取り消しをやり直すことになる。 + + 印はここでは消さない。**呼び出し側が完了の記録と同じ保存で消す。** 先に消すと、 + 完了を記録する前に落ちたときに、次の実行が「取り消し済みだが未完了」の状態を + 見分けられなくなる。 + """ + entry["pending_drop"] = list(targets) + entry["pending_push"] = True + statefile.save(path, state) + result = _drop_items(state, entry, list(targets)) + statefile.save(path, state) + return result + + def _apply_drop( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], failed: list[str], @@ -1351,13 +1373,7 @@ def _apply_drop( 取り消しと積み直しのコミットを「未割当」と判定してラウンドごと巻き込む。 """ work = state["worktrees"]["work"] - entry["pending_drop"] = list(failed) - # 取り消しへ着手する**前に**再送信の印も立てる。取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - - result = _drop_items(state, entry, list(failed)) + result = _run_drop(path, state, entry, failed) applied = list(entry["apply"].get("applied") or []) if result["mode"] == "round": # 積み直せなかった。合意済みの項目も含めて全件捨てる。 @@ -1375,11 +1391,11 @@ def _apply_drop( entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) state["phase"] = "propose" + # **取り消しが済んだことを push より先に、印の解除と同じ保存で永続化する。** + # 保存せずに push して失敗すると、次の実行が適用の検証をやり直し、取り消しと + # 積み直しのコミットを「未割当」と判定してラウンドごと巻き込んでしまう。 + # `pending_push` は残るので、次の実行は push の再送だけを行う。 entry["pending_drop"] = [] - # **取り消しが済んだことを push より先に永続化する。** 保存せずに push して - # 失敗すると、次の実行が適用の検証をやり直し、取り消しと積み直しのコミットを - # 「未割当」と判定してラウンドごと巻き込んでしまう。`pending_push` は残るので - # 次の実行は push の再送だけを行う。 entry["apply"]["merged_at"] = statefile.now() statefile.save(path, state) _push_head(state) @@ -1547,7 +1563,13 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: path, state = _load(args.id) entry = _round(state, args.round) if not args.dry_run: - _flush_pending_push(path, state, entry) + # **やり残した取り消しを push の再送より先に片づける。** 先に push すると、 + # 取り消しが途中の HEAD をそのまま Pull Request へ反映してしまう。 + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + _run_drop(path, state, entry, list(entry["pending_drop"])) + else: + _flush_pending_push(path, state, entry) # 取り消し自体は `reverted` で冪等だが、見送りの記録は重複しうる。 if entry.get("abandoned") is not None: @@ -1573,11 +1595,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: info("(dry-run)状態ファイルは更新していません") return - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、Pull Request 側には未検証の差分が残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - result = _drop_items(state, entry, targets) + result = _run_drop(path, state, entry, targets) if result["mode"] == "round": info("積み直せなかったため、このラウンドで適用した項目を全件見送ります") targets = list(entry["apply"].get("applied") or targets) @@ -1597,9 +1615,11 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: }) info(f"↩ {item_id} を見送りました") + # 見送りの記録と印の解除を**同じ保存で**行う。保存してから push するので、 + # push が失敗しても記録とローカルの git が食い違わない。 entry["abandoned"] = targets + entry["pending_drop"] = [] state["phase"] = "propose" - # 保存してから push する。push が失敗しても、記録とローカルの git が食い違わない。 statefile.save(path, state) _push_head(state) entry["pending_push"] = False @@ -1735,8 +1755,11 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: "commits": list(ordered_range)}, dry_run=False, ) - # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 needs_push = True diff --git a/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py index e5c46d59..63b2bb64 100644 --- a/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf-shared/skills/cross-refactoring/tests/test_abandon_items.py @@ -870,3 +870,59 @@ def spying_run(cmd, **kwargs): assert marks and marks[0] is True assert read_state(state_path)["rounds"][0]["pending_push"] is False + + +def test_abandon_saves_the_drop_result_before_pushing( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """取り消しの結果を push より先に保存すること。 + + 保存しないまま落ちると、積み直しで変わった SHA と取り消し済みの印が失われ、 + 次の実行が**履歴に無い SHA を相手に**取り消しをやり直す。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + env_tmp_dir(state_path) + _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + seen: list[dict] = [] + monkeypatch.setattr( + refactor, "_sh", + lambda cmd, **k: seen.append(read_state(state_path)) or "", + ) + refactor.cmd_abandon_items(_args()) + + assert seen, "push が実行されていない" + at_push = seen[0] + by_id = {i["item_id"]: i for i in at_push["items"]} + assert by_id["R1-002"]["commits"] == ["new-sha-R1-002"], "SHA の追従が保存前" + assert by_id["R1-001"]["reverted"] is True + assert at_push["rounds"][0]["pending_drop"] == [] + + +def test_abandon_retries_the_drop_before_resending_the_push( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """やり残した取り消しは、push の再送より先に片づけること。 + + 先に push すると、取り消しが途中の HEAD をそのまま公開してしまう。 + """ + state_path = _range_state(tmp_path, [_finding("R1-001")]) + state = read_state(state_path) + state["rounds"][0]["pending_drop"] = ["R1-001"] + state["rounds"][0]["pending_push"] = True + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + env_tmp_dir(state_path) + + order: list[str] = [] + calls = _range_env(refactor, monkeypatch, ["sha-R1-002", "sha-R1-001"]) + real_run = refactor.subprocess.run + monkeypatch.setattr( + refactor.subprocess, "run", + lambda cmd, **kw: (order.append(cmd[1]) if cmd[:1] == ["git"] else None) + or real_run(cmd, **kw), + ) + monkeypatch.setattr(refactor, "_sh", lambda cmd, **k: order.append("push") or "") + refactor.cmd_abandon_items(_args()) + + assert "revert" in order and "push" in order + assert order.index("revert") < order.index("push"), "取り消しより先に push している" + assert read_state(state_path)["rounds"][0]["pending_push"] is False