From a68ac24bf4275c680ebbaf9d90bb866fb062d0cf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:05:27 +0000 Subject: [PATCH 01/90] =?UTF-8?q?Docs:=20cross-refactoring=206=20=E5=9B=9E?= =?UTF-8?q?=E7=9B=AE=E3=81=AE=E5=AE=9F=E6=A9=9F=E8=A9=A6=E8=A1=8C=E3=81=AE?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XpZXeYvfEGymigQeCLp1Eh --- .../issue-113-cross-refactoring-6th-trial.md | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 issues/issue-113-cross-refactoring-6th-trial.md diff --git a/issues/issue-113-cross-refactoring-6th-trial.md b/issues/issue-113-cross-refactoring-6th-trial.md new file mode 100644 index 0000000..e9145f3 --- /dev/null +++ b/issues/issue-113-cross-refactoring-6th-trial.md @@ -0,0 +1,78 @@ +# cross-refactoring 6 回目の実機試行 + +Pull Request #133 / #134 / #135(NDF v8.5.2〜v8.5.4)で直した 4 件が実機で成立するかを +確かめる。あわせて、5 回の試行で一度も到達していない経路まで通す。 + +経緯は次の 5 つにある。 + +- [issue-113-cross-refactoring-trial-report.md](issue-113-cross-refactoring-trial-report.md) — 1 回目 +- [issue-113-cross-refactoring-retrial.md](issue-113-cross-refactoring-retrial.md) — 2 回目 +- [issue-113-cross-refactoring-re-retrial.md](issue-113-cross-refactoring-re-retrial.md) — 3 回目 +- [issue-113-cross-refactoring-4th-trial-report.md](issue-113-cross-refactoring-4th-trial-report.md) — 4 回目 +- [issue-113-cross-refactoring-5th-trial-report.md](issue-113-cross-refactoring-5th-trial-report.md) — 5 回目 + +## 目的 + +5 回目はレビューの投稿が成立せず、レビュー結果の欠落を理由に 2 ラウンド目で中断した。 +投稿にまつわる 3 件と差分予算の 1 件を直したので、同じ構成でもう一度通す。 + +## 確かめること + +| # | 直したこと | 何が観測できれば通ったと言えるか | +| --- | --- | --- | +| 18 | 自分の Pull Request では投稿の event を `COMMENT` へ倒す | レビュー担当が `HTTP 422` を受けずに投稿でき、結果ファイルの判定は `APPROVE` / `REQUEST_CHANGES` のまま残る | +| 19 | 投稿に失敗しても結果ファイルを書く | 投稿が失敗したラウンドで `post_error` 付きの結果ファイルが残り、担当の停止と投稿の失敗を区別できる | +| 20 | 投稿されたことを GitHub 側で確かめる | 結果ファイルのレビュー URL が必須になり、GitHub 側に無い判定は差し戻される | +| 21 | 抽出系の手法の差分予算 | `long_method` の抽出が見積の 2 倍をわずかに超えても採用され、範囲外を触った変更は落ちる | + +## 到達したい範囲 + +5 回の試行で一度も到達していない。 + +| 対象 | 内容 | +| --- | --- | +| 提案の収束 | 新しい提案が出なくなって `advance` が繰り返しを終えるところ | +| 修正ラウンドの上限 | `should-abandon` から `abandon-items` へ進み、指摘が残る項目だけを取り消すところ | +| Step 7 の関門 | 収束後に `/ndf:cross-review` を Pull Request 全体へ実行するところ | +| Draft の解除 | Step 8 の報告と Draft 解除 | + +## 対象範囲 + +| パス | 内容 | +| --- | --- | +| `plugins/ndf-shared/skills/cross-refactoring/scripts/` | `refactor.py` / `launch-cli.sh` / `prepare-worktrees.sh` | +| `plugins/ndf-shared/skills/cross-refactoring/tests/` | 現状固定テスト | +| `plugins/ndf-shared/skills/cross-review/scripts/lib/` | 収束ループの共通層 | +| `plugins/ndf-shared/skills/cross-review/tests/` | 現状固定テスト | + +## 実行条件 + +| 項目 | 値 | +| --- | --- | +| ホスト | Claude Code(提案・レビューには不参加) | +| 提案・レビュー | codex / gemini / kiro | +| 適用の母集合 | claude / codex / kiro | +| 使用する版 | プラグインキャッシュの v8.5.4 | +| kiro のモデル | `claude-sonnet-5`(既定の `auto` は集計から分離されるため指定する) | +| ラウンド上限 | 3 | +| 着手前のテスト | 500 passed | + +```bash +/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 \ + plugins/ndf-shared/skills/cross-review/tests \ + --model kiro=claude-sonnet-5 \ + --sync-command "bash scripts/build-runtime-plugins.sh" \ + --baseline-test "uv run --with pytest python -m pytest \ + plugins/ndf-shared/skills/cross-refactoring/tests \ + plugins/ndf-shared/skills/cross-review/tests -q" \ + --max-outer-rounds 3 +``` + +## 注意 + +`plugins/ndf-shared/` は編集元であり、`scripts/build-runtime-plugins.sh` で +`ndf-claude` / `ndf-codex` / `ndf-kiro` へ同期する。同期は進行側の責務のため +`--sync-command` で渡す。 From 0aa1eb7d2ae87ac7de18cc726b49377d83c0ecfd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:12:05 +0000 Subject: [PATCH 02/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codex sentinel 完了判定を _check_codex_sentinel_done に抽出し、monitor_agent のループ本体は完了ステータスの返却だけに寄せた。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 59 ++++++++++++------- 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 401a760..432555c 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,6 +613,40 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" +def _check_codex_sentinel_done( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, + alive: bool, + log_prefix: str, +) -> Optional[AgentStatus]: + if agent != "codex": + return None + status.sentinel_seen = _scan_codex_sentinel(paths.err_log) + + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + if ( + alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status + return None + + def monitor_agent( agent: str, pr: int, @@ -669,29 +703,10 @@ def monitor_agent( # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) alive = _pid_alive(pid) - if agent == "codex": - status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - - # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - agent == "codex" - and alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 + if done_status := _check_codex_sentinel_done( + agent, pid, paths, status, alive, log_prefix ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + return done_status # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け From 4ccc7f940c17638fe1780748345ebb62be03e0d8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:12:57 +0000 Subject: [PATCH 03/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit result.json の経過時間 fallback 判定を _check_stale_result_done に抽出し、完了判定の順序を維持した。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 75 ++++++++++++------- 1 file changed, 48 insertions(+), 27 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 432555c..65aa232 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -647,6 +647,51 @@ def _check_codex_sentinel_done( return None +def _check_stale_result_done( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, + alive: bool, + cmdline_validated: bool, + started_wall: float, + log_prefix: str, +) -> Optional[AgentStatus]: + # result.json が書かれた後もプロセスがハングするケース (gemini で観測: + # MCP サーバー切断待ち等で exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if not ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 + ): + return None + + result_mtime = paths.result.stat().st_mtime + if result_mtime < started_wall: + return None + result_age = time.time() - result_mtime + if result_age < RESULT_AGE_GRACE: + return None + + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status + + def monitor_agent( agent: str, pr: int, @@ -708,34 +753,10 @@ def monitor_agent( ): return done_status - # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: - # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け - # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - # 完了とみなし、プロセスを kill → OK。 - # 安全条件: - # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - # - mtime >= started_wall: 前 round の stale result.json を拾わない - if ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 + if done_status := _check_stale_result_done( + agent, pid, paths, status, alive, cmdline_validated, started_wall, log_prefix ): - result_mtime = paths.result.stat().st_mtime - if result_mtime >= started_wall: - result_age = time.time() - result_mtime - if result_age >= RESULT_AGE_GRACE: - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status + return done_status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと From a1f8ac6062664bb1e0d2ab13a3f1d897d653a75f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:13:46 +0000 Subject: [PATCH 04/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmdline の stale pidfile 判定を _check_cmdline_stale に抽出し、検証済みフラグを戻り値で更新する形にした。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 44 +++++++++++++------ 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 65aa232..c62721b 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -692,6 +692,32 @@ def _check_stale_result_done( return status +def _check_cmdline_stale( + agent: str, + pid: int, + status: AgentStatus, + alive: bool, + cmdline_validated: bool, + log_prefix: str, +) -> tuple[Optional[AgentStatus], bool]: + if not alive or cmdline_validated: + return None, cmdline_validated + + # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと + # ファイル不在で None 扱いになり判定不能のため。 + cmdline_ok = _pid_cmdline_matches(pid, agent) + if cmdline_ok is False: + _kill_pid(pid) + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pid {pid} cmdline does not contain '{agent}' (stale pidfile?)" + _emit_log(log_prefix, agent, status) + return status, cmdline_validated + if cmdline_ok is True: + return None, True + return None, cmdline_validated + + def monitor_agent( agent: str, pr: int, @@ -758,19 +784,11 @@ def monitor_agent( ): return done_status - if alive and not cmdline_validated: - # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと - # ファイル不在で None 扱いになり判定不能のため。 - cmdline_ok = _pid_cmdline_matches(pid, agent) - if cmdline_ok is False: - _kill_pid(pid) - status.status = "PIDFILE_BAD" - status.exit_code = 6 - status.detail = f"pid {pid} cmdline does not contain '{agent}' (stale pidfile?)" - _emit_log(log_prefix, agent, status) - return status - if cmdline_ok is True: - cmdline_validated = True + done_status, cmdline_validated = _check_cmdline_stale( + agent, pid, status, alive, cmdline_validated, log_prefix + ) + if done_status: + return done_status # 2. hard timeout if elapsed >= timeout: From 79de742ad4d1050b075e024b682b5df4686c83f4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:14:28 +0000 Subject: [PATCH 05/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hard timeout 判定を _check_hard_timeout に抽出し、監視ループ内の終了条件を単純化した。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index c62721b..3a55b23 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -718,6 +718,26 @@ def _check_cmdline_stale( return None, cmdline_validated +def _check_hard_timeout( + agent: str, + pid: int, + status: AgentStatus, + alive: bool, + elapsed: float, + timeout: int, + log_prefix: str, +) -> Optional[AgentStatus]: + if elapsed < timeout: + return None + if alive: + _kill_pid(pid) + status.status = "TIMEOUT" + status.exit_code = 2 + status.detail = f"hard timeout {timeout}s reached (pid {pid})" + _emit_log(log_prefix, agent, status) + return status + + def monitor_agent( agent: str, pr: int, @@ -790,15 +810,10 @@ def monitor_agent( if done_status: return done_status - # 2. hard timeout - if elapsed >= timeout: - if alive: - _kill_pid(pid) - status.status = "TIMEOUT" - status.exit_code = 2 - status.detail = f"hard timeout {timeout}s reached (pid {pid})" - _emit_log(log_prefix, agent, status) - return status + if done_status := _check_hard_timeout( + agent, pid, status, alive, elapsed, timeout, log_prefix + ): + return done_status # 3. early error # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は From 52391e3fd53e07b0d49f4fb8d1a9afaed6cba1a0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:15:25 +0000 Subject: [PATCH 06/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit early error の FATAL/WARN 判定を _check_early_error に抽出し、WARN 済み状態を戻り値で保持した。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 85 ++++++++++++------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 3a55b23..a38e8b7 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -738,6 +738,53 @@ def _check_hard_timeout( return status +def _check_early_error( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, + alive: bool, + no_early_error: bool, + warned_early_error: bool, + log_prefix: str, +) -> tuple[Optional[AgentStatus], bool]: + if no_early_error: + return None, warned_early_error + + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status, warned_early_error + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + return None, True + return None, warned_early_error + + def monitor_agent( agent: str, pr: int, @@ -815,39 +862,11 @@ def monitor_agent( ): return done_status - # 3. early error - # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を - # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - if not no_early_error: - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status - - if not warned_early_error: - warn_err = _scan_early_warn(paths.err_log) - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - warned_early_error = True + done_status, warned_early_error = _check_early_error( + agent, pid, paths, status, alive, no_early_error, warned_early_error, log_prefix + ) + if done_status: + return done_status if not alive: # プロセス終了 — result.json を確認 From a302bc2f827931984b69a19941b8f9ebf5a30ecb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:16:10 +0000 Subject: [PATCH 07/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit プロセス終了後の OK/NO_RESULT 判定を _check_process_exited に抽出し、result.json 確認を独立させた。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index a38e8b7..dc474b4 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -785,6 +785,34 @@ def _check_early_error( return None, warned_early_error +def _check_process_exited( + agent: str, + paths: AgentPaths, + status: AgentStatus, + alive: bool, + require_result: bool, + log_prefix: str, +) -> Optional[AgentStatus]: + if alive: + return None + + # プロセス終了 — result.json を確認 + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status + + def monitor_agent( agent: str, pr: int, @@ -868,22 +896,10 @@ def monitor_agent( if done_status: return done_status - if not alive: - # プロセス終了 — result.json を確認 - status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 - if status.result_exists or not require_result: - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"process exited; sentinel={status.sentinel_seen}; " - f"result_exists={status.result_exists}" - ) - else: - status.status = "NO_RESULT" - status.exit_code = 3 - status.detail = f"process exited but result.json missing: {paths.result}" - _emit_log(log_prefix, agent, status) - return status + if done_status := _check_process_exited( + agent, paths, status, alive, require_result, log_prefix + ): + return done_status # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には From 8e947474d979e0761ef0670d364a8115b7f655c6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:17:01 +0000 Subject: [PATCH 08/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-review=20monitor.py#monitor=5Fagent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stall 検知を _check_stall に抽出し、進捗サイズと最終進捗時刻を戻り値で更新する形にした。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/monitor.py | 70 ++++++++++++------- 1 file changed, 45 insertions(+), 25 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index dc474b4..8d2d813 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -813,6 +813,46 @@ def _check_process_exited( return status +def _check_stall( + agent: str, + pid: int, + paths: AgentPaths, + status: AgentStatus, + alive: bool, + stall_timeout: int, + last_progress_size: int, + last_progress: float, + log_prefix: str, +) -> tuple[Optional[AgentStatus], int, float]: + # stall detection (err.log / stdout.log / progress.log をモニタ。 + # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には + # launcher が要求した短いフェーズマーカーが出るため、いずれかが + # 更新されれば progress として扱う) + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size + ) + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status, last_progress_size, last_progress + return None, last_progress_size, last_progress + + def monitor_agent( agent: str, pr: int, @@ -901,32 +941,12 @@ def monitor_agent( ): return done_status - # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 - # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には - # launcher が要求した短いフェーズマーカーが出るため、いずれかが - # 更新されれば progress として扱う) - status.err_log_size = _safe_size(paths.err_log) - status.stdout_log_size = _safe_size(paths.stdout_log) - status.progress_log_size = _safe_size(paths.progress_log) - status.progress_tail = _tail_last_nonempty_line(paths.progress_log) - progress_size = ( - status.err_log_size + status.stdout_log_size + status.progress_log_size + done_status, last_progress_size, last_progress = _check_stall( + agent, pid, paths, status, alive, stall_timeout, + last_progress_size, last_progress, log_prefix ) - if progress_size != last_progress_size: - last_progress_size = progress_size - last_progress = time.monotonic() - status.idle_seconds = time.monotonic() - last_progress - if status.idle_seconds >= stall_timeout: - if alive: - _kill_pid(pid) - status.status = "STALLED" - status.exit_code = 5 - status.detail = ( - f"no log progress for {stall_timeout}s " - f"(pid {pid}, last size {last_progress_size}B)" - ) - _emit_log(log_prefix, agent, status) - return status + if done_status: + return done_status # poll 中の進捗ログ _emit_progress(log_prefix, agent, status) From 6d56e8473bb500884e08d6b0c0d9e891d5ae8b69 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:18:19 +0000 Subject: [PATCH 09/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用結果・baseline・git 範囲を読む段を _load_apply_context に切り出し、cmd_merge_apply の前半を段階化した。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 96 +++++++++++-------- 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b3cae92..34f6646 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1294,48 +1294,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) return - impl = entry["impl"] - result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) - payload = _read_result(result, impl) - - _record_observed_model(entry, "impl", impl, state, "apply", args.round) - - # 着手前のテストが**成功と確認できていない限り**適用結果を採らない。 - # `red` だけでなく `unknown`(確認していない)も拒否する。確認していない状態を - # 通すと、「壊したのか元から壊れていたのか」を判別する手段が無いまま進む。 - baseline = state.get("baseline_test") or {} - if baseline.get("status") != "green": - for item_id in entry["items"]: - _find_item(state, item_id)["status"] = "blocked" - if not args.dry_run: - statefile.save(path, state) - die( - f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" - "適用へ着手しません(全項目を blocked)", - code=2, - ) - - # 検証の材料は git から取る。結果ファイルから使うのは - # 「どのコミットがどの項目のものか」という対応付けだけ。 - work = state["worktrees"]["work"] - head_branch = state["head_branch"] - test_command = baseline["command"] - head_sha = _git_out(work, ["rev-parse", "HEAD"]) or "" - # 起点は `merge-proposals` が記録したもの。**実装担当の申告は使わない。** - ordered_range = commits_in_range(work, entry.get("apply_base_sha"), head_sha) - in_range = set(ordered_range or []) - if ordered_range is None: - # 範囲を確定できないなら、何も検証できない。素通しにせず失敗させる。 - for item_id in entry["items"]: - _find_item(state, item_id)["status"] = "blocked" - if not args.dry_run: - statefile.save(path, state) - die( - "適用の範囲を確定できませんでした" - f"(起点 {entry.get('apply_base_sha')} / HEAD {head_sha})。" - "検証できない適用は採りません", - code=2, - ) + payload, work, head_branch, test_command, head_sha, ordered_range, in_range = ( + _load_apply_context(path, state, entry, args) + ) # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 @@ -1529,6 +1490,57 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _load_apply_context( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, +) -> tuple[dict[str, Any], pathlib.Path, str, str, str, list[str], set[str]]: + impl = entry["impl"] + result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) + payload = _read_result(result, impl) + + _record_observed_model(entry, "impl", impl, state, "apply", args.round) + + # 着手前のテストが**成功と確認できていない限り**適用結果を採らない。 + # `red` だけでなく `unknown`(確認していない)も拒否する。確認していない状態を + # 通すと、「壊したのか元から壊れていたのか」を判別する手段が無いまま進む。 + baseline = state.get("baseline_test") or {} + if baseline.get("status") != "green": + for item_id in entry["items"]: + _find_item(state, item_id)["status"] = "blocked" + if not args.dry_run: + statefile.save(path, state) + die( + f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" + "適用へ着手しません(全項目を blocked)", + code=2, + ) + + # 検証の材料は git から取る。結果ファイルから使うのは + # 「どのコミットがどの項目のものか」という対応付けだけ。 + work = state["worktrees"]["work"] + head_branch = state["head_branch"] + test_command = baseline["command"] + head_sha = _git_out(work, ["rev-parse", "HEAD"]) or "" + # 起点は `merge-proposals` が記録したもの。**実装担当の申告は使わない。** + ordered_range = commits_in_range(work, entry.get("apply_base_sha"), head_sha) + in_range = set(ordered_range or []) + if ordered_range is None: + # 範囲を確定できないなら、何も検証できない。素通しにせず失敗させる。 + for item_id in entry["items"]: + _find_item(state, item_id)["status"] = "blocked" + if not args.dry_run: + statefile.save(path, state) + die( + "適用の範囲を確定できませんでした" + f"(起点 {entry.get('apply_base_sha')} / HEAD {head_sha})。" + "検証できない適用は採りません", + code=2, + ) + return payload, work, head_branch, test_command, head_sha, ordered_range, in_range + + def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 303605718c92638e00dd38ff009b92c78245bffd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:19:09 +0000 Subject: [PATCH 10/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit payload の申告項目を _collect_apply_reports に切り出し、ラウンド内申告と未知 ID の収集を独立させた。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 34f6646..c7a5419 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1298,24 +1298,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _load_apply_context(path, state, entry, args) ) - # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた - # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 - # そのまま Pull Request に残せてしまう。 - round_items = set(entry["items"]) - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id in round_items: - reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) + reported, unknown_ids = _collect_apply_reports(payload, entry) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1541,6 +1524,31 @@ def _load_apply_context( return payload, work, head_branch, test_command, head_sha, ordered_range, in_range +def _collect_apply_reports( + payload: dict[str, Any], + entry: dict[str, Any], +) -> tuple[dict[str, dict[str, Any]], list[str]]: + # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた + # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 + # そのまま Pull Request に残せてしまう。 + round_items = set(entry["items"]) + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id in round_items: + reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) + return reported, unknown_ids + + def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 4c67c4af9bebb2fb0d29e28fcd4599c8977998d3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:20:38 +0000 Subject: [PATCH 11/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コミット所有権の検証とラウンド取り消しを _validate_apply_commit_ownership に切り出した。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 190 ++++++++++-------- 1 file changed, 105 insertions(+), 85 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index c7a5419..89d83e5 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1300,91 +1300,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, unknown_ids = _collect_apply_reports(payload, entry) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - whole_round = { - "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: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 - # 残さないと同じ提案が次のラウンドで再び採用される。 - _defer_abandoned_items(state, entry) - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + _validate_apply_commit_ownership( + path, state, entry, args, reported, unknown_ids, work, + ordered_range, in_range, head_sha, + ) applied: list[str] = [] failed: list[str] = [] @@ -1549,6 +1468,107 @@ def _collect_apply_reports( return reported, unknown_ids +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + # 実際の差分が食い違い、どちらが正しいか決められなくなる。 + # + # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(in_range - set(owner_of)) + if not (unassigned or unknown_ids or duplicated): + return + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + whole_round = { + "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: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 + # 残さないと同じ提案が次のラウンドで再び採用される。 + _defer_abandoned_items(state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + + def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 6003f5b060ca517703c23f12c7bba3defa12a97f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:21:46 +0000 Subject: [PATCH 12/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 項目ごとの commit facts 収集と適用結果検証を _verify_apply_items に切り出し、progress 保存を段として分離した。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 107 ++++++++++-------- 1 file changed, 62 insertions(+), 45 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 89d83e5..0123f2d 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1305,51 +1305,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: ordered_range, in_range, head_sha, ) - applied: list[str] = [] - failed: list[str] = [] - 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) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - 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, 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) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - 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) + applied, failed = _verify_apply_items( + path, state, entry, args, reported, work, in_range, test_command, head_branch, + ) entry["apply"] = { "applied": applied, @@ -1569,6 +1527,65 @@ def _validate_apply_commit_ownership( sys.exit(2) +def _verify_apply_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + work: pathlib.Path, + in_range: set[str], + test_command: str, + head_branch: str, +) -> tuple[list[str], list[str]]: + applied: list[str] = [] + failed: list[str] = [] + 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) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + 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, 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) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + 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) + return applied, failed + + def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From b78b7b5656bb7d163805ed520da3053224646f4c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:23:26 +0000 Subject: [PATCH 13/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fjudge=5Freview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー判定の集計、記録作成、担当別所要時間の更新を _aggregate_review_results に抽出した。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0123f2d..6c54c75 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1759,38 +1759,9 @@ def cmd_judge_review(args: argparse.Namespace) -> None: sys.exit(seen["exit"]) return - verdict, problems = judge(reviews, reviewers, entry["items"]) - if post_problems: - verdict = "invalid" - problems = problems + post_problems - - # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも - # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 - record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} - for name in reviewers: - review = reviews.get(name) - review = review if isinstance(review, dict) else {} - record[name] = review.get("verdict") - findings = review.get("findings") - for finding in findings if isinstance(findings, list) else []: - if not isinstance(finding, dict): - continue - record["findings"].append({ - "reviewer": name, - "item_id": finding.get("item_id"), - "thread_id": finding.get("thread_id"), - "summary": finding.get("summary"), - "resolved": bool(finding.get("resolved")), - }) - entry["reviews"].append(record) - # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 - # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 - per_reviewer = entry.setdefault("reviewer_seconds", {}) - for name in reviewers: - review = reviews.get(name) - elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 - per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) - entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + verdict, problems, record = _aggregate_review_results( + entry, reviewers, reviews, post_problems + ) statefile.save(path, state) def _remember(exit_code: int) -> None: @@ -1862,6 +1833,47 @@ def _remember(exit_code: int) -> None: sys.exit(2) +def _aggregate_review_results( + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + post_problems: list[str], +) -> tuple[str, list[str], dict[str, Any]]: + verdict, problems = judge(reviews, reviewers, entry["items"]) + if post_problems: + verdict = "invalid" + problems = problems + post_problems + + # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも + # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 + record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} + for name in reviewers: + review = reviews.get(name) + review = review if isinstance(review, dict) else {} + record[name] = review.get("verdict") + findings = review.get("findings") + for finding in findings if isinstance(findings, list) else []: + if not isinstance(finding, dict): + continue + record["findings"].append({ + "reviewer": name, + "item_id": finding.get("item_id"), + "thread_id": finding.get("thread_id"), + "summary": finding.get("summary"), + "resolved": bool(finding.get("resolved")), + }) + entry["reviews"].append(record) + # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 + # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 + per_reviewer = entry.setdefault("reviewer_seconds", {}) + for name in reviewers: + review = reviews.get(name) + elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 + per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) + entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + return verdict, problems, record + + def cmd_should_abandon(args: argparse.Namespace) -> None: """Step 6 — 修正ラウンドの上限に達したか。 From 9a54fcea1609e37998a5610b4103875c4215f3a1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:24:45 +0000 Subject: [PATCH 14/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fjudge=5Freview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー判定後の invalid/approved/request changes 分岐を _handle_review_verdict に抽出した。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6c54c75..8bce5f3 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -35,7 +35,7 @@ import subprocess import sys import time -from typing import Any, Iterable, Optional +from typing import Any, Callable, Iterable, Optional sys.path.insert( 0, @@ -1769,6 +1769,24 @@ def _remember(exit_code: int) -> None: {"key": review_key, "exit": exit_code} ) + _handle_review_verdict( + path, state, entry, reviewers, reviews, unposted, + verdict, problems, record, _remember, + ) + + +def _handle_review_verdict( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + unposted: list[str], + verdict: str, + problems: list[str], + record: dict[str, Any], + remember: Callable[[int], None], +) -> None: if verdict == "invalid": for p in problems: info(f"❌ {p}") @@ -1783,7 +1801,7 @@ def _remember(exit_code: int) -> None: # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 blocked = missing + [name for name in unposted if name not in missing] if blocked: - _remember(ABORT) + remember(ABORT) statefile.save(path, state) die( f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" @@ -1809,11 +1827,11 @@ def _remember(exit_code: int) -> None: # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 # 上限へ永久に到達せず修正と再レビューを往復し続ける。 _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) info("差し戻しの上限に達したため、変更要求として扱います") sys.exit(2) - _remember(3) + remember(3) statefile.save(path, state) info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") sys.exit(3) @@ -1821,12 +1839,12 @@ def _remember(exit_code: int) -> None: for item_id in entry["apply"]["applied"]: _find_item(state, item_id)["status"] = "done" state["phase"] = "propose" - _remember(0) + remember(0) statefile.save(path, state) info("✅ レビュー担当 2 者とも承認しました") return _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) open_findings = sum(1 for f in record["findings"] if not f["resolved"]) info(f"変更要求があります(未解決の指摘 {open_findings} 件)") From 32cd565e3c438477782c535e9b10a02aa6046fc8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:25:42 +0000 Subject: [PATCH 15/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-review=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当のラウンド集計を _aggregate_impl_round に切り出し、aggregate の走査処理から分離した。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index c2b576b..0edaaf9 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -69,29 +69,8 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: "実装担当の集計から分離する" ) - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 + _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): @@ -137,6 +116,38 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: } +def _aggregate_impl_round( + impl: dict[str, dict[str, Any]], + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl_runtime: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From 60190490beb8ec8295f8eb1173bafb0595a427d8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:26:33 +0000 Subject: [PATCH 16/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-review=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー担当ごとの指標更新を _aggregate_reviewer_round に切り出し、担当別集計を独立させた。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 0edaaf9..1ce6600 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -85,29 +85,7 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: f"round {round_no}: {name} が既定モデル(auto)で動いたため、" "レビュー担当の集計から分離する" ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, @@ -148,6 +126,38 @@ def _aggregate_impl_round( bucket["first_review_approved"] += 1 if approved_first else 0 +def _aggregate_reviewer_round( + reviewer: dict[str, dict[str, Any]], + entry: dict[str, Any], + name: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + rb = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From 8ca3321bbe8f7bddda2cb939f0eed7913ed99216 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:27:18 +0000 Subject: [PATCH 17/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-review=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit モデル不一致と auto モデルの unmeasured 追記を _append_model_measurement_warnings に集約した。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 1ce6600..f3fb139 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -60,14 +60,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: requested = impl_model.get("requested") observed = impl_model.get("observed") - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) + _append_model_measurement_warnings( + unmeasured, round_no, impl_runtime, requested, observed, "実装担当" + ) reviews = _round_reviews(entry) _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) @@ -77,14 +72,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: spec = reviewer_models.get(name) or {} r_requested = spec.get("requested") r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) + _append_model_measurement_warnings( + unmeasured, round_no, name, r_requested, r_observed, "レビュー担当" + ) _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) return { @@ -158,6 +148,24 @@ def _aggregate_reviewer_round( ) +def _append_model_measurement_warnings( + unmeasured: list[str], + round_no: Any, + runtime: str, + requested: Optional[str], + observed: Optional[str], + role_label: str, +) -> None: + warning = _models.mismatch_warning(runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(runtime, requested): + unmeasured.append( + f"round {round_no}: {runtime} が既定モデル(auto)で動いたため、" + f"{role_label}の集計から分離する" + ) + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From e5ff2251929cbd6320fc461a69af7c841c11efa4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 18/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20cross-review=20metrics.py#aggregate"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8ca3321bbe8f7bddda2cb939f0eed7913ed99216. --- .../cross-review/scripts/lib/metrics.py | 40 ++++++++----------- 1 file changed, 16 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index f3fb139..1ce6600 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -60,9 +60,14 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: requested = impl_model.get("requested") observed = impl_model.get("observed") - _append_model_measurement_warnings( - unmeasured, round_no, impl_runtime, requested, observed, "実装担当" - ) + warning = _models.mismatch_warning(impl_runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(impl_runtime, requested): + unmeasured.append( + f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" + "実装担当の集計から分離する" + ) reviews = _round_reviews(entry) _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) @@ -72,9 +77,14 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: spec = reviewer_models.get(name) or {} r_requested = spec.get("requested") r_observed = spec.get("observed") - _append_model_measurement_warnings( - unmeasured, round_no, name, r_requested, r_observed, "レビュー担当" - ) + r_warning = _models.mismatch_warning(name, r_requested, r_observed) + if r_warning: + unmeasured.append(f"round {round_no}: {r_warning}") + if not _models.is_measurable(name, r_requested): + unmeasured.append( + f"round {round_no}: {name} が既定モデル(auto)で動いたため、" + "レビュー担当の集計から分離する" + ) _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) return { @@ -148,24 +158,6 @@ def _aggregate_reviewer_round( ) -def _append_model_measurement_warnings( - unmeasured: list[str], - round_no: Any, - runtime: str, - requested: Optional[str], - observed: Optional[str], - role_label: str, -) -> None: - warning = _models.mismatch_warning(runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(runtime, requested): - unmeasured.append( - f"round {round_no}: {runtime} が既定モデル(auto)で動いたため、" - f"{role_label}の集計から分離する" - ) - - def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From 798a0d2f1b2e1d1726d98dbbe3a58fdd7556b3f3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 19/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20cross-review=20metrics.py#aggregate"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 60190490beb8ec8295f8eb1173bafb0595a427d8. --- .../cross-review/scripts/lib/metrics.py | 56 ++++++++----------- 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 1ce6600..0edaaf9 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -85,7 +85,29 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: f"round {round_no}: {name} が既定モデル(auto)で動いたため、" "レビュー担当の集計から分離する" ) - _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) + rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, @@ -126,38 +148,6 @@ def _aggregate_impl_round( bucket["first_review_approved"] += 1 if approved_first else 0 -def _aggregate_reviewer_round( - reviewer: dict[str, dict[str, Any]], - entry: dict[str, Any], - name: str, - requested: Optional[str], - reviews: list[dict[str, Any]], -) -> None: - rb = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) - - def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From 643a63e8a5785c59b7aff7807cbc57faefcd68e1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 20/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20cross-review=20metrics.py#aggregate"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 32cd565e3c438477782c535e9b10a02aa6046fc8. --- .../cross-review/scripts/lib/metrics.py | 55 ++++++++----------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 0edaaf9..c2b576b 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -69,8 +69,29 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: "実装担当の集計から分離する" ) + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + reviews = _round_reviews(entry) - _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): @@ -116,38 +137,6 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: } -def _aggregate_impl_round( - impl: dict[str, dict[str, Any]], - entry: dict[str, Any], - items_by_id: dict[str, dict[str, Any]], - impl_runtime: str, - requested: Optional[str], - reviews: list[dict[str, Any]], -) -> None: - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 - - def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From 940ab86e7a2b677af838c0924d537ce4775733f8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 21/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fjudge=5Frevie?= =?UTF-8?q?w"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 9a54fcea1609e37998a5610b4103875c4215f3a1. --- .../cross-refactoring/scripts/refactor.py | 30 ++++--------------- 1 file changed, 6 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 8bce5f3..6c54c75 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -35,7 +35,7 @@ import subprocess import sys import time -from typing import Any, Callable, Iterable, Optional +from typing import Any, Iterable, Optional sys.path.insert( 0, @@ -1769,24 +1769,6 @@ def _remember(exit_code: int) -> None: {"key": review_key, "exit": exit_code} ) - _handle_review_verdict( - path, state, entry, reviewers, reviews, unposted, - verdict, problems, record, _remember, - ) - - -def _handle_review_verdict( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - reviewers: list[str], - reviews: dict[str, dict[str, Any]], - unposted: list[str], - verdict: str, - problems: list[str], - record: dict[str, Any], - remember: Callable[[int], None], -) -> None: if verdict == "invalid": for p in problems: info(f"❌ {p}") @@ -1801,7 +1783,7 @@ def _handle_review_verdict( # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 blocked = missing + [name for name in unposted if name not in missing] if blocked: - remember(ABORT) + _remember(ABORT) statefile.save(path, state) die( f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" @@ -1827,11 +1809,11 @@ def _handle_review_verdict( # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 # 上限へ永久に到達せず修正と再レビューを往復し続ける。 _prepare_fix_phase(state, entry) - remember(2) + _remember(2) statefile.save(path, state) info("差し戻しの上限に達したため、変更要求として扱います") sys.exit(2) - remember(3) + _remember(3) statefile.save(path, state) info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") sys.exit(3) @@ -1839,12 +1821,12 @@ def _handle_review_verdict( for item_id in entry["apply"]["applied"]: _find_item(state, item_id)["status"] = "done" state["phase"] = "propose" - remember(0) + _remember(0) statefile.save(path, state) info("✅ レビュー担当 2 者とも承認しました") return _prepare_fix_phase(state, entry) - remember(2) + _remember(2) statefile.save(path, state) open_findings = sum(1 for f in record["findings"] if not f["resolved"]) info(f"変更要求があります(未解決の指摘 {open_findings} 件)") From b57d623eb7ef7dbf0b0ea97bd71f177e1db73046 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 22/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fjudge=5Frevie?= =?UTF-8?q?w"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b78b7b5656bb7d163805ed520da3053224646f4c. --- .../cross-refactoring/scripts/refactor.py | 76 ++++++++----------- 1 file changed, 32 insertions(+), 44 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6c54c75..0123f2d 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1759,9 +1759,38 @@ def cmd_judge_review(args: argparse.Namespace) -> None: sys.exit(seen["exit"]) return - verdict, problems, record = _aggregate_review_results( - entry, reviewers, reviews, post_problems - ) + verdict, problems = judge(reviews, reviewers, entry["items"]) + if post_problems: + verdict = "invalid" + problems = problems + post_problems + + # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも + # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 + record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} + for name in reviewers: + review = reviews.get(name) + review = review if isinstance(review, dict) else {} + record[name] = review.get("verdict") + findings = review.get("findings") + for finding in findings if isinstance(findings, list) else []: + if not isinstance(finding, dict): + continue + record["findings"].append({ + "reviewer": name, + "item_id": finding.get("item_id"), + "thread_id": finding.get("thread_id"), + "summary": finding.get("summary"), + "resolved": bool(finding.get("resolved")), + }) + entry["reviews"].append(record) + # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 + # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 + per_reviewer = entry.setdefault("reviewer_seconds", {}) + for name in reviewers: + review = reviews.get(name) + elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 + per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) + entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) statefile.save(path, state) def _remember(exit_code: int) -> None: @@ -1833,47 +1862,6 @@ def _remember(exit_code: int) -> None: sys.exit(2) -def _aggregate_review_results( - entry: dict[str, Any], - reviewers: list[str], - reviews: dict[str, dict[str, Any]], - post_problems: list[str], -) -> tuple[str, list[str], dict[str, Any]]: - verdict, problems = judge(reviews, reviewers, entry["items"]) - if post_problems: - verdict = "invalid" - problems = problems + post_problems - - # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも - # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 - record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} - for name in reviewers: - review = reviews.get(name) - review = review if isinstance(review, dict) else {} - record[name] = review.get("verdict") - findings = review.get("findings") - for finding in findings if isinstance(findings, list) else []: - if not isinstance(finding, dict): - continue - record["findings"].append({ - "reviewer": name, - "item_id": finding.get("item_id"), - "thread_id": finding.get("thread_id"), - "summary": finding.get("summary"), - "resolved": bool(finding.get("resolved")), - }) - entry["reviews"].append(record) - # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 - # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 - per_reviewer = entry.setdefault("reviewer_seconds", {}) - for name in reviewers: - review = reviews.get(name) - elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 - per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) - entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) - return verdict, problems, record - - def cmd_should_abandon(args: argparse.Namespace) -> None: """Step 6 — 修正ラウンドの上限に達したか。 From 90a26f7265574442741ce5d7b3b145da2b37ecb0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 23/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge?= =?UTF-8?q?=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6003f5b060ca517703c23f12c7bba3defa12a97f. --- .../cross-refactoring/scripts/refactor.py | 107 ++++++++---------- 1 file changed, 45 insertions(+), 62 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0123f2d..89d83e5 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1305,9 +1305,51 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: ordered_range, in_range, head_sha, ) - applied, failed = _verify_apply_items( - path, state, entry, args, reported, work, in_range, test_command, head_branch, - ) + applied: list[str] = [] + failed: list[str] = [] + 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) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + 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, 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) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + 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, @@ -1527,65 +1569,6 @@ def _validate_apply_commit_ownership( sys.exit(2) -def _verify_apply_items( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - args: argparse.Namespace, - reported: dict[str, dict[str, Any]], - work: pathlib.Path, - in_range: set[str], - test_command: str, - head_branch: str, -) -> tuple[list[str], list[str]]: - applied: list[str] = [] - failed: list[str] = [] - 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) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - 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, 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) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - 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) - return applied, failed - - def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From c9d422e1404fdb0fcf426af553496b502b5cd7b5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 24/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge?= =?UTF-8?q?=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4c67c4af9bebb2fb0d29e28fcd4599c8977998d3. --- .../cross-refactoring/scripts/refactor.py | 190 ++++++++---------- 1 file changed, 85 insertions(+), 105 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 89d83e5..c7a5419 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1300,10 +1300,91 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, unknown_ids = _collect_apply_reports(payload, entry) - _validate_apply_commit_ownership( - path, state, entry, args, reported, unknown_ids, work, - ordered_range, in_range, head_sha, - ) + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + # 実際の差分が食い違い、どちらが正しいか決められなくなる。 + # + # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(in_range - set(owner_of)) + if unassigned or unknown_ids or duplicated: + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + whole_round = { + "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: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 + # 残さないと同じ提案が次のラウンドで再び採用される。 + _defer_abandoned_items(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] = [] @@ -1468,107 +1549,6 @@ def _collect_apply_reports( return reported, unknown_ids -def _validate_apply_commit_ownership( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - args: argparse.Namespace, - reported: dict[str, dict[str, Any]], - unknown_ids: list[str], - work: pathlib.Path, - ordered_range: list[str], - in_range: set[str], - head_sha: str, -) -> None: - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) - if not (unassigned or unknown_ids or duplicated): - return - - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - whole_round = { - "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: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 - # 残さないと同じ提案が次のラウンドで再び採用される。 - _defer_abandoned_items(state, entry) - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) - - def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 922002e75b5df1a67ddf607d56dbb95d67cceba6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 25/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge?= =?UTF-8?q?=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 303605718c92638e00dd38ff009b92c78245bffd. --- .../cross-refactoring/scripts/refactor.py | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index c7a5419..34f6646 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1298,7 +1298,24 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _load_apply_context(path, state, entry, args) ) - reported, unknown_ids = _collect_apply_reports(payload, entry) + # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた + # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 + # そのまま Pull Request に残せてしまう。 + round_items = set(entry["items"]) + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id in round_items: + reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1524,31 +1541,6 @@ def _load_apply_context( return payload, work, head_branch, test_command, head_sha, ordered_range, in_range -def _collect_apply_reports( - payload: dict[str, Any], - entry: dict[str, Any], -) -> tuple[dict[str, dict[str, Any]], list[str]]: - # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた - # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 - # そのまま Pull Request に残せてしまう。 - round_items = set(entry["items"]) - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id in round_items: - reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) - return reported, unknown_ids - - def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From dcb8506075e52254a175d4c03ac3aed9e05f526a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 26/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge?= =?UTF-8?q?=5Fapply"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6d56e8473bb500884e08d6b0c0d9e891d5ae8b69. --- .../cross-refactoring/scripts/refactor.py | 96 ++++++++----------- 1 file changed, 42 insertions(+), 54 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 34f6646..b3cae92 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1294,9 +1294,48 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) return - payload, work, head_branch, test_command, head_sha, ordered_range, in_range = ( - _load_apply_context(path, state, entry, args) - ) + impl = entry["impl"] + result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) + payload = _read_result(result, impl) + + _record_observed_model(entry, "impl", impl, state, "apply", args.round) + + # 着手前のテストが**成功と確認できていない限り**適用結果を採らない。 + # `red` だけでなく `unknown`(確認していない)も拒否する。確認していない状態を + # 通すと、「壊したのか元から壊れていたのか」を判別する手段が無いまま進む。 + baseline = state.get("baseline_test") or {} + if baseline.get("status") != "green": + for item_id in entry["items"]: + _find_item(state, item_id)["status"] = "blocked" + if not args.dry_run: + statefile.save(path, state) + die( + f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" + "適用へ着手しません(全項目を blocked)", + code=2, + ) + + # 検証の材料は git から取る。結果ファイルから使うのは + # 「どのコミットがどの項目のものか」という対応付けだけ。 + work = state["worktrees"]["work"] + head_branch = state["head_branch"] + test_command = baseline["command"] + head_sha = _git_out(work, ["rev-parse", "HEAD"]) or "" + # 起点は `merge-proposals` が記録したもの。**実装担当の申告は使わない。** + ordered_range = commits_in_range(work, entry.get("apply_base_sha"), head_sha) + in_range = set(ordered_range or []) + if ordered_range is None: + # 範囲を確定できないなら、何も検証できない。素通しにせず失敗させる。 + for item_id in entry["items"]: + _find_item(state, item_id)["status"] = "blocked" + if not args.dry_run: + statefile.save(path, state) + die( + "適用の範囲を確定できませんでした" + f"(起点 {entry.get('apply_base_sha')} / HEAD {head_sha})。" + "検証できない適用は採りません", + code=2, + ) # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 @@ -1490,57 +1529,6 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) -def _load_apply_context( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - args: argparse.Namespace, -) -> tuple[dict[str, Any], pathlib.Path, str, str, str, list[str], set[str]]: - impl = entry["impl"] - result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) - payload = _read_result(result, impl) - - _record_observed_model(entry, "impl", impl, state, "apply", args.round) - - # 着手前のテストが**成功と確認できていない限り**適用結果を採らない。 - # `red` だけでなく `unknown`(確認していない)も拒否する。確認していない状態を - # 通すと、「壊したのか元から壊れていたのか」を判別する手段が無いまま進む。 - baseline = state.get("baseline_test") or {} - if baseline.get("status") != "green": - for item_id in entry["items"]: - _find_item(state, item_id)["status"] = "blocked" - if not args.dry_run: - statefile.save(path, state) - die( - f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" - "適用へ着手しません(全項目を blocked)", - code=2, - ) - - # 検証の材料は git から取る。結果ファイルから使うのは - # 「どのコミットがどの項目のものか」という対応付けだけ。 - work = state["worktrees"]["work"] - head_branch = state["head_branch"] - test_command = baseline["command"] - head_sha = _git_out(work, ["rev-parse", "HEAD"]) or "" - # 起点は `merge-proposals` が記録したもの。**実装担当の申告は使わない。** - ordered_range = commits_in_range(work, entry.get("apply_base_sha"), head_sha) - in_range = set(ordered_range or []) - if ordered_range is None: - # 範囲を確定できないなら、何も検証できない。素通しにせず失敗させる。 - for item_id in entry["items"]: - _find_item(state, item_id)["status"] = "blocked" - if not args.dry_run: - statefile.save(path, state) - die( - "適用の範囲を確定できませんでした" - f"(起点 {entry.get('apply_base_sha')} / HEAD {head_sha})。" - "検証できない適用は採りません", - code=2, - ) - return payload, work, head_branch, test_command, head_sha, ordered_range, in_range - - def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 879db88664b1624864a9922993778276344a12ce Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 27/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8e947474d979e0761ef0670d364a8115b7f655c6. --- .../cross-review/scripts/lib/monitor.py | 70 +++++++------------ 1 file changed, 25 insertions(+), 45 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 8d2d813..dc474b4 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -813,46 +813,6 @@ def _check_process_exited( return status -def _check_stall( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, - alive: bool, - stall_timeout: int, - last_progress_size: int, - last_progress: float, - log_prefix: str, -) -> tuple[Optional[AgentStatus], int, float]: - # stall detection (err.log / stdout.log / progress.log をモニタ。 - # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には - # launcher が要求した短いフェーズマーカーが出るため、いずれかが - # 更新されれば progress として扱う) - status.err_log_size = _safe_size(paths.err_log) - status.stdout_log_size = _safe_size(paths.stdout_log) - status.progress_log_size = _safe_size(paths.progress_log) - status.progress_tail = _tail_last_nonempty_line(paths.progress_log) - progress_size = ( - status.err_log_size + status.stdout_log_size + status.progress_log_size - ) - if progress_size != last_progress_size: - last_progress_size = progress_size - last_progress = time.monotonic() - status.idle_seconds = time.monotonic() - last_progress - if status.idle_seconds >= stall_timeout: - if alive: - _kill_pid(pid) - status.status = "STALLED" - status.exit_code = 5 - status.detail = ( - f"no log progress for {stall_timeout}s " - f"(pid {pid}, last size {last_progress_size}B)" - ) - _emit_log(log_prefix, agent, status) - return status, last_progress_size, last_progress - return None, last_progress_size, last_progress - - def monitor_agent( agent: str, pr: int, @@ -941,12 +901,32 @@ def monitor_agent( ): return done_status - done_status, last_progress_size, last_progress = _check_stall( - agent, pid, paths, status, alive, stall_timeout, - last_progress_size, last_progress, log_prefix + # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 + # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には + # launcher が要求した短いフェーズマーカーが出るため、いずれかが + # 更新されれば progress として扱う) + status.err_log_size = _safe_size(paths.err_log) + status.stdout_log_size = _safe_size(paths.stdout_log) + status.progress_log_size = _safe_size(paths.progress_log) + status.progress_tail = _tail_last_nonempty_line(paths.progress_log) + progress_size = ( + status.err_log_size + status.stdout_log_size + status.progress_log_size ) - if done_status: - return done_status + if progress_size != last_progress_size: + last_progress_size = progress_size + last_progress = time.monotonic() + status.idle_seconds = time.monotonic() - last_progress + if status.idle_seconds >= stall_timeout: + if alive: + _kill_pid(pid) + status.status = "STALLED" + status.exit_code = 5 + status.detail = ( + f"no log progress for {stall_timeout}s " + f"(pid {pid}, last size {last_progress_size}B)" + ) + _emit_log(log_prefix, agent, status) + return status # poll 中の進捗ログ _emit_progress(log_prefix, agent, status) From 4699b061afc52c41cd99be4936cd37fb1c8befa7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 28/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a302bc2f827931984b69a19941b8f9ebf5a30ecb. --- .../cross-review/scripts/lib/monitor.py | 48 +++++++------------ 1 file changed, 16 insertions(+), 32 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index dc474b4..a38e8b7 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -785,34 +785,6 @@ def _check_early_error( return None, warned_early_error -def _check_process_exited( - agent: str, - paths: AgentPaths, - status: AgentStatus, - alive: bool, - require_result: bool, - log_prefix: str, -) -> Optional[AgentStatus]: - if alive: - return None - - # プロセス終了 — result.json を確認 - status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 - if status.result_exists or not require_result: - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"process exited; sentinel={status.sentinel_seen}; " - f"result_exists={status.result_exists}" - ) - else: - status.status = "NO_RESULT" - status.exit_code = 3 - status.detail = f"process exited but result.json missing: {paths.result}" - _emit_log(log_prefix, agent, status) - return status - - def monitor_agent( agent: str, pr: int, @@ -896,10 +868,22 @@ def monitor_agent( if done_status: return done_status - if done_status := _check_process_exited( - agent, paths, status, alive, require_result, log_prefix - ): - return done_status + if not alive: + # プロセス終了 — result.json を確認 + status.result_exists = paths.result.exists() and paths.result.stat().st_size > 0 + if status.result_exists or not require_result: + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"process exited; sentinel={status.sentinel_seen}; " + f"result_exists={status.result_exists}" + ) + else: + status.status = "NO_RESULT" + status.exit_code = 3 + status.detail = f"process exited but result.json missing: {paths.result}" + _emit_log(log_prefix, agent, status) + return status # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 # gemini は stdout 側だけ進捗が出るケースがあり、progress.log には From f189f4314f6277b023c5df77b46126c30b5d1c69 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 29/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 52391e3fd53e07b0d49f4fb8d1a9afaed6cba1a0. --- .../cross-review/scripts/lib/monitor.py | 85 +++++++------------ 1 file changed, 33 insertions(+), 52 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index a38e8b7..3a55b23 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -738,53 +738,6 @@ def _check_hard_timeout( return status -def _check_early_error( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, - alive: bool, - no_early_error: bool, - warned_early_error: bool, - log_prefix: str, -) -> tuple[Optional[AgentStatus], bool]: - if no_early_error: - return None, warned_early_error - - # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は - # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を - # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - if fatal_err: - if alive: - _kill_pid(pid) - status.status = "EARLY_ERROR" - status.exit_code = 4 - # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに - # 存在しない行を探させることになる。 - status.detail = ( - f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" - ) - _emit_log(log_prefix, agent, status) - return status, warned_early_error - - if not warned_early_error: - warn_err = _scan_early_warn(paths.err_log) - if warn_err: - print( - f"{log_prefix}⚠️ {agent} early-error WARN " - f"(non-fatal, not killing): {warn_err[:200]}", - file=sys.stderr, flush=True, - ) - return None, True - return None, warned_early_error - - def monitor_agent( agent: str, pr: int, @@ -862,11 +815,39 @@ def monitor_agent( ): return done_status - done_status, warned_early_error = _check_early_error( - agent, pid, paths, status, alive, no_early_error, warned_early_error, log_prefix - ) - if done_status: - return done_status + # 3. early error + # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は + # WARN として警告ログのみ。codex がレビュー対象 diff の test コード片を + # echo するケースや gemini の config validation 警告で誤 kill されるのを防ぐ。 + if not no_early_error: + fatal_err = _scan_early_fatal(paths.err_log) + fatal_source = "err.log" + if not fatal_err and agent == "claude": + # claude は承認失敗・実行失敗を標準出力の JSON に載せる。 + fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) + fatal_source = "stdout.log" + if fatal_err: + if alive: + _kill_pid(pid) + status.status = "EARLY_ERROR" + status.exit_code = 4 + # 検知元を書く。err.log と決め打ちすると、標準出力から検知したときに + # 存在しない行を探させることになる。 + status.detail = ( + f"early error (fatal) in {fatal_source}: {fatal_err[:200]}" + ) + _emit_log(log_prefix, agent, status) + return status + + if not warned_early_error: + warn_err = _scan_early_warn(paths.err_log) + if warn_err: + print( + f"{log_prefix}⚠️ {agent} early-error WARN " + f"(non-fatal, not killing): {warn_err[:200]}", + file=sys.stderr, flush=True, + ) + warned_early_error = True if not alive: # プロセス終了 — result.json を確認 From 30667512c7d3580136b803c9d3f7ecfb0d344ac6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 30/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 79de742ad4d1050b075e024b682b5df4686c83f4. --- .../cross-review/scripts/lib/monitor.py | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 3a55b23..c62721b 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -718,26 +718,6 @@ def _check_cmdline_stale( return None, cmdline_validated -def _check_hard_timeout( - agent: str, - pid: int, - status: AgentStatus, - alive: bool, - elapsed: float, - timeout: int, - log_prefix: str, -) -> Optional[AgentStatus]: - if elapsed < timeout: - return None - if alive: - _kill_pid(pid) - status.status = "TIMEOUT" - status.exit_code = 2 - status.detail = f"hard timeout {timeout}s reached (pid {pid})" - _emit_log(log_prefix, agent, status) - return status - - def monitor_agent( agent: str, pr: int, @@ -810,10 +790,15 @@ def monitor_agent( if done_status: return done_status - if done_status := _check_hard_timeout( - agent, pid, status, alive, elapsed, timeout, log_prefix - ): - return done_status + # 2. hard timeout + if elapsed >= timeout: + if alive: + _kill_pid(pid) + status.status = "TIMEOUT" + status.exit_code = 2 + status.detail = f"hard timeout {timeout}s reached (pid {pid})" + _emit_log(log_prefix, agent, status) + return status # 3. early error # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は From eb359326fa5f32ad9f7d503d0d53ff5b54f9aea8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 31/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit a1f8ac6062664bb1e0d2ab13a3f1d897d653a75f. --- .../cross-review/scripts/lib/monitor.py | 44 ++++++------------- 1 file changed, 13 insertions(+), 31 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index c62721b..65aa232 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -692,32 +692,6 @@ def _check_stale_result_done( return status -def _check_cmdline_stale( - agent: str, - pid: int, - status: AgentStatus, - alive: bool, - cmdline_validated: bool, - log_prefix: str, -) -> tuple[Optional[AgentStatus], bool]: - if not alive or cmdline_validated: - return None, cmdline_validated - - # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと - # ファイル不在で None 扱いになり判定不能のため。 - cmdline_ok = _pid_cmdline_matches(pid, agent) - if cmdline_ok is False: - _kill_pid(pid) - status.status = "PIDFILE_BAD" - status.exit_code = 6 - status.detail = f"pid {pid} cmdline does not contain '{agent}' (stale pidfile?)" - _emit_log(log_prefix, agent, status) - return status, cmdline_validated - if cmdline_ok is True: - return None, True - return None, cmdline_validated - - def monitor_agent( agent: str, pr: int, @@ -784,11 +758,19 @@ def monitor_agent( ): return done_status - done_status, cmdline_validated = _check_cmdline_stale( - agent, pid, status, alive, cmdline_validated, log_prefix - ) - if done_status: - return done_status + if alive and not cmdline_validated: + # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと + # ファイル不在で None 扱いになり判定不能のため。 + cmdline_ok = _pid_cmdline_matches(pid, agent) + if cmdline_ok is False: + _kill_pid(pid) + status.status = "PIDFILE_BAD" + status.exit_code = 6 + status.detail = f"pid {pid} cmdline does not contain '{agent}' (stale pidfile?)" + _emit_log(log_prefix, agent, status) + return status + if cmdline_ok is True: + cmdline_validated = True # 2. hard timeout if elapsed >= timeout: From 3769343b78fd7e710a3f013319f6920a36f53baa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 32/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4ccc7f940c17638fe1780748345ebb62be03e0d8. --- .../cross-review/scripts/lib/monitor.py | 75 +++++++------------ 1 file changed, 27 insertions(+), 48 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 65aa232..432555c 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -647,51 +647,6 @@ def _check_codex_sentinel_done( return None -def _check_stale_result_done( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, - alive: bool, - cmdline_validated: bool, - started_wall: float, - log_prefix: str, -) -> Optional[AgentStatus]: - # result.json が書かれた後もプロセスがハングするケース (gemini で観測: - # MCP サーバー切断待ち等で exit しない)。sentinel 機構を持たない agent 向け - # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば - # 完了とみなし、プロセスを kill → OK。 - # 安全条件: - # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み - # - mtime >= started_wall: 前 round の stale result.json を拾わない - if not ( - alive - and not status.sentinel_seen - and cmdline_validated - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - return None - - result_mtime = paths.result.stat().st_mtime - if result_mtime < started_wall: - return None - result_age = time.time() - result_mtime - if result_age < RESULT_AGE_GRACE: - return None - - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"result.json exists for {result_age:.0f}s without process exit; " - f"killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status - - def monitor_agent( agent: str, pr: int, @@ -753,10 +708,34 @@ def monitor_agent( ): return done_status - if done_status := _check_stale_result_done( - agent, pid, paths, status, alive, cmdline_validated, started_wall, log_prefix + # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: + # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け + # の fallback: result.json の mtime が RESULT_AGE_GRACE 秒以上前であれば + # 完了とみなし、プロセスを kill → OK。 + # 安全条件: + # - cmdline_validated: PID 再利用でない (または検証不能環境) ことを確認済み + # - mtime >= started_wall: 前 round の stale result.json を拾わない + if ( + alive + and not status.sentinel_seen + and cmdline_validated + and paths.result.exists() + and paths.result.stat().st_size > 0 ): - return done_status + result_mtime = paths.result.stat().st_mtime + if result_mtime >= started_wall: + result_age = time.time() - result_mtime + if result_age >= RESULT_AGE_GRACE: + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"result.json exists for {result_age:.0f}s without process exit; " + f"killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status if alive and not cmdline_validated: # cmdline 検証は alive 確認後に 1 回だけ。生きていない瞬間に proc/ を読むと From 6fab0aa4e6a36e09a1d982370222f7415fde415e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:14 +0000 Subject: [PATCH 33/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20monitor.py#monitor=5Fagent"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0aa1eb7d2ae87ac7de18cc726b49377d83c0ecfd. --- .../cross-review/scripts/lib/monitor.py | 59 +++++++------------ 1 file changed, 22 insertions(+), 37 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py index 432555c..401a760 100755 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/monitor.py @@ -613,40 +613,6 @@ def _tail_last_nonempty_line(path: pathlib.Path, limit: int = 4096) -> str: return "" -def _check_codex_sentinel_done( - agent: str, - pid: int, - paths: AgentPaths, - status: AgentStatus, - alive: bool, - log_prefix: str, -) -> Optional[AgentStatus]: - if agent != "codex": - return None - status.sentinel_seen = _scan_codex_sentinel(paths.err_log) - - # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける - # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の - # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が - # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 - if ( - alive - and status.sentinel_seen - and paths.result.exists() - and paths.result.stat().st_size > 0 - ): - _kill_pid(pid) - status.result_exists = True - status.status = "OK" - status.exit_code = 0 - status.detail = ( - f"codex sentinel + result.json detected; killed lingering pid {pid}" - ) - _emit_log(log_prefix, agent, status) - return status - return None - - def monitor_agent( agent: str, pr: int, @@ -703,10 +669,29 @@ def monitor_agent( # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) alive = _pid_alive(pid) - if done_status := _check_codex_sentinel_done( - agent, pid, paths, status, alive, log_prefix + if agent == "codex": + status.sentinel_seen = _scan_codex_sentinel(paths.err_log) + + # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける + # ケースがある (実機で観測)。result.json は正常に書かれているのに alive=True の + # まま stall_timeout に達して STALLED 化してしまう。sentinel + result.json が + # 揃った瞬間に対象プロセスを kill して OK 判定で返す。 + if ( + agent == "codex" + and alive + and status.sentinel_seen + and paths.result.exists() + and paths.result.stat().st_size > 0 ): - return done_status + _kill_pid(pid) + status.result_exists = True + status.status = "OK" + status.exit_code = 0 + status.detail = ( + f"codex sentinel + result.json detected; killed lingering pid {pid}" + ) + _emit_log(log_prefix, agent, status) + return status # result.json が書かれた後もプロセスがハング��るケース (gemini で観測: # MCP サーバー切断待ち等��� exit しない)。sentinel 機構を持たない agent 向け From f0d59775ffcf26b28777832010e776ada9f4f283 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:18:19 +0000 Subject: [PATCH 34/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用結果・baseline・git 範囲を読む段を _load_apply_context に切り出し、cmd_merge_apply の前半を段階化した。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 96 +++++++++++-------- 1 file changed, 54 insertions(+), 42 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b3cae92..34f6646 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1294,48 +1294,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) return - impl = entry["impl"] - result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) - payload = _read_result(result, impl) - - _record_observed_model(entry, "impl", impl, state, "apply", args.round) - - # 着手前のテストが**成功と確認できていない限り**適用結果を採らない。 - # `red` だけでなく `unknown`(確認していない)も拒否する。確認していない状態を - # 通すと、「壊したのか元から壊れていたのか」を判別する手段が無いまま進む。 - baseline = state.get("baseline_test") or {} - if baseline.get("status") != "green": - for item_id in entry["items"]: - _find_item(state, item_id)["status"] = "blocked" - if not args.dry_run: - statefile.save(path, state) - die( - f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" - "適用へ着手しません(全項目を blocked)", - code=2, - ) - - # 検証の材料は git から取る。結果ファイルから使うのは - # 「どのコミットがどの項目のものか」という対応付けだけ。 - work = state["worktrees"]["work"] - head_branch = state["head_branch"] - test_command = baseline["command"] - head_sha = _git_out(work, ["rev-parse", "HEAD"]) or "" - # 起点は `merge-proposals` が記録したもの。**実装担当の申告は使わない。** - ordered_range = commits_in_range(work, entry.get("apply_base_sha"), head_sha) - in_range = set(ordered_range or []) - if ordered_range is None: - # 範囲を確定できないなら、何も検証できない。素通しにせず失敗させる。 - for item_id in entry["items"]: - _find_item(state, item_id)["status"] = "blocked" - if not args.dry_run: - statefile.save(path, state) - die( - "適用の範囲を確定できませんでした" - f"(起点 {entry.get('apply_base_sha')} / HEAD {head_sha})。" - "検証できない適用は採りません", - code=2, - ) + payload, work, head_branch, test_command, head_sha, ordered_range, in_range = ( + _load_apply_context(path, state, entry, args) + ) # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 @@ -1529,6 +1490,57 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) +def _load_apply_context( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, +) -> tuple[dict[str, Any], pathlib.Path, str, str, str, list[str], set[str]]: + impl = entry["impl"] + result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) + payload = _read_result(result, impl) + + _record_observed_model(entry, "impl", impl, state, "apply", args.round) + + # 着手前のテストが**成功と確認できていない限り**適用結果を採らない。 + # `red` だけでなく `unknown`(確認していない)も拒否する。確認していない状態を + # 通すと、「壊したのか元から壊れていたのか」を判別する手段が無いまま進む。 + baseline = state.get("baseline_test") or {} + if baseline.get("status") != "green": + for item_id in entry["items"]: + _find_item(state, item_id)["status"] = "blocked" + if not args.dry_run: + statefile.save(path, state) + die( + f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" + "適用へ着手しません(全項目を blocked)", + code=2, + ) + + # 検証の材料は git から取る。結果ファイルから使うのは + # 「どのコミットがどの項目のものか」という対応付けだけ。 + work = state["worktrees"]["work"] + head_branch = state["head_branch"] + test_command = baseline["command"] + head_sha = _git_out(work, ["rev-parse", "HEAD"]) or "" + # 起点は `merge-proposals` が記録したもの。**実装担当の申告は使わない。** + ordered_range = commits_in_range(work, entry.get("apply_base_sha"), head_sha) + in_range = set(ordered_range or []) + if ordered_range is None: + # 範囲を確定できないなら、何も検証できない。素通しにせず失敗させる。 + for item_id in entry["items"]: + _find_item(state, item_id)["status"] = "blocked" + if not args.dry_run: + statefile.save(path, state) + die( + "適用の範囲を確定できませんでした" + f"(起点 {entry.get('apply_base_sha')} / HEAD {head_sha})。" + "検証できない適用は採りません", + code=2, + ) + return payload, work, head_branch, test_command, head_sha, ordered_range, in_range + + def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 7dcebc16c25a40d34d949a7248a5014d9a8c01f1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:19:09 +0000 Subject: [PATCH 35/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit payload の申告項目を _collect_apply_reports に切り出し、ラウンド内申告と未知 ID の収集を独立させた。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 34f6646..c7a5419 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1298,24 +1298,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: _load_apply_context(path, state, entry, args) ) - # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた - # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 - # そのまま Pull Request に残せてしまう。 - round_items = set(entry["items"]) - reported: dict[str, dict[str, Any]] = {} - unknown_ids: list[str] = [] - raw_items = payload.get("items") - if not isinstance(raw_items, list): - info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") - raw_items = [] - for r in raw_items: - if not isinstance(r, dict): - continue - item_id = r.get("item_id") - if item_id in round_items: - reported[item_id] = r - elif item_id is not None: - unknown_ids.append(str(item_id)) + reported, unknown_ids = _collect_apply_reports(payload, entry) # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま @@ -1541,6 +1524,31 @@ def _load_apply_context( return payload, work, head_branch, test_command, head_sha, ordered_range, in_range +def _collect_apply_reports( + payload: dict[str, Any], + entry: dict[str, Any], +) -> tuple[dict[str, dict[str, Any]], list[str]]: + # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた + # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 + # そのまま Pull Request に残せてしまう。 + round_items = set(entry["items"]) + reported: dict[str, dict[str, Any]] = {} + unknown_ids: list[str] = [] + raw_items = payload.get("items") + if not isinstance(raw_items, list): + info(f"⚠ 適用結果の items が配列ではありません({type(raw_items).__name__})") + raw_items = [] + for r in raw_items: + if not isinstance(r, dict): + continue + item_id = r.get("item_id") + if item_id in round_items: + reported[item_id] = r + elif item_id is not None: + unknown_ids.append(str(item_id)) + return reported, unknown_ids + + def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 658adc58c193fd702f2fb2855505475302efeb71 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:20:38 +0000 Subject: [PATCH 36/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コミット所有権の検証とラウンド取り消しを _validate_apply_commit_ownership に切り出した。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 190 ++++++++++-------- 1 file changed, 105 insertions(+), 85 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index c7a5419..89d83e5 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1300,91 +1300,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported, unknown_ids = _collect_apply_reports(payload, entry) - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) - - unassigned = sorted(in_range - set(owner_of)) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - whole_round = { - "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: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 - # 残さないと同じ提案が次のラウンドで再び採用される。 - _defer_abandoned_items(state, entry) - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + _validate_apply_commit_ownership( + path, state, entry, args, reported, unknown_ids, work, + ordered_range, in_range, head_sha, + ) applied: list[str] = [] failed: list[str] = [] @@ -1549,6 +1468,107 @@ def _collect_apply_reports( return reported, unknown_ids +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + # 実際の差分が食い違い、どちらが正しいか決められなくなる。 + # + # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + + unassigned = sorted(in_range - set(owner_of)) + if not (unassigned or unknown_ids or duplicated): + return + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + whole_round = { + "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: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 + # 残さないと同じ提案が次のラウンドで再び採用される。 + _defer_abandoned_items(state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + + def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 2b9dd8f252f22c175bf35a53c31f699b8c800e3d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:21:46 +0000 Subject: [PATCH 37/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fapply?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 項目ごとの commit facts 収集と適用結果検証を _verify_apply_items に切り出し、progress 保存を段として分離した。 Item-Id: R1-002 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 107 ++++++++++-------- 1 file changed, 62 insertions(+), 45 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 89d83e5..0123f2d 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1305,51 +1305,9 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: ordered_range, in_range, head_sha, ) - applied: list[str] = [] - failed: list[str] = [] - 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) - if got is None: - problem = "適用結果に項目がありません" - facts: list[dict[str, Any]] = [] - else: - facts = collect_commit_facts( - 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, 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) - item["out_of_scope"] = bool(got and "対象範囲の外" in problem) - # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに - # その場で戻すと、まだ判定していない項目のコミットと競合する。 - item["commits"] = _reported_shas(got) - failed.append(item_id) - info(f"❌ {item_id}: {problem}") - 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) + applied, failed = _verify_apply_items( + path, state, entry, args, reported, work, in_range, test_command, head_branch, + ) entry["apply"] = { "applied": applied, @@ -1569,6 +1527,65 @@ def _validate_apply_commit_ownership( sys.exit(2) +def _verify_apply_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + work: pathlib.Path, + in_range: set[str], + test_command: str, + head_branch: str, +) -> tuple[list[str], list[str]]: + applied: list[str] = [] + failed: list[str] = [] + 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) + if got is None: + problem = "適用結果に項目がありません" + facts: list[dict[str, Any]] = [] + else: + facts = collect_commit_facts( + 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, 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) + item["out_of_scope"] = bool(got and "対象範囲の外" in problem) + # 取り消しは全項目の判定が出そろってから**まとめて**行う。項目ごとに + # その場で戻すと、まだ判定していない項目のコミットと競合する。 + item["commits"] = _reported_shas(got) + failed.append(item_id) + info(f"❌ {item_id}: {problem}") + 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) + return applied, failed + + def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: """このラウンドで取り消した項目を「対象外」として記録する。 From 1696deaccb6f6e74f22ad64856a833c32356f9eb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:23:26 +0000 Subject: [PATCH 38/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fjudge=5Freview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー判定の集計、記録作成、担当別所要時間の更新を _aggregate_review_results に抽出した。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 76 +++++++++++-------- 1 file changed, 44 insertions(+), 32 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 0123f2d..6c54c75 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1759,38 +1759,9 @@ def cmd_judge_review(args: argparse.Namespace) -> None: sys.exit(seen["exit"]) return - verdict, problems = judge(reviews, reviewers, entry["items"]) - if post_problems: - verdict = "invalid" - problems = problems + post_problems - - # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも - # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 - record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} - for name in reviewers: - review = reviews.get(name) - review = review if isinstance(review, dict) else {} - record[name] = review.get("verdict") - findings = review.get("findings") - for finding in findings if isinstance(findings, list) else []: - if not isinstance(finding, dict): - continue - record["findings"].append({ - "reviewer": name, - "item_id": finding.get("item_id"), - "thread_id": finding.get("thread_id"), - "summary": finding.get("summary"), - "resolved": bool(finding.get("resolved")), - }) - entry["reviews"].append(record) - # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 - # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 - per_reviewer = entry.setdefault("reviewer_seconds", {}) - for name in reviewers: - review = reviews.get(name) - elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 - per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) - entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + verdict, problems, record = _aggregate_review_results( + entry, reviewers, reviews, post_problems + ) statefile.save(path, state) def _remember(exit_code: int) -> None: @@ -1862,6 +1833,47 @@ def _remember(exit_code: int) -> None: sys.exit(2) +def _aggregate_review_results( + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + post_problems: list[str], +) -> tuple[str, list[str], dict[str, Any]]: + verdict, problems = judge(reviews, reviewers, entry["items"]) + if post_problems: + verdict = "invalid" + problems = problems + post_problems + + # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも + # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 + record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} + for name in reviewers: + review = reviews.get(name) + review = review if isinstance(review, dict) else {} + record[name] = review.get("verdict") + findings = review.get("findings") + for finding in findings if isinstance(findings, list) else []: + if not isinstance(finding, dict): + continue + record["findings"].append({ + "reviewer": name, + "item_id": finding.get("item_id"), + "thread_id": finding.get("thread_id"), + "summary": finding.get("summary"), + "resolved": bool(finding.get("resolved")), + }) + entry["reviews"].append(record) + # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 + # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 + per_reviewer = entry.setdefault("reviewer_seconds", {}) + for name in reviewers: + review = reviews.get(name) + elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 + per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) + entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + return verdict, problems, record + + def cmd_should_abandon(args: argparse.Namespace) -> None: """Step 6 — 修正ラウンドの上限に達したか。 From fac46480071e3d0b34e652c9f00c54753672606b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:24:45 +0000 Subject: [PATCH 39/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fjudge=5Freview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー判定後の invalid/approved/request changes 分岐を _handle_review_verdict に抽出した。 Item-Id: R1-003 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6c54c75..8bce5f3 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -35,7 +35,7 @@ import subprocess import sys import time -from typing import Any, Iterable, Optional +from typing import Any, Callable, Iterable, Optional sys.path.insert( 0, @@ -1769,6 +1769,24 @@ def _remember(exit_code: int) -> None: {"key": review_key, "exit": exit_code} ) + _handle_review_verdict( + path, state, entry, reviewers, reviews, unposted, + verdict, problems, record, _remember, + ) + + +def _handle_review_verdict( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + unposted: list[str], + verdict: str, + problems: list[str], + record: dict[str, Any], + remember: Callable[[int], None], +) -> None: if verdict == "invalid": for p in problems: info(f"❌ {p}") @@ -1783,7 +1801,7 @@ def _remember(exit_code: int) -> None: # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 blocked = missing + [name for name in unposted if name not in missing] if blocked: - _remember(ABORT) + remember(ABORT) statefile.save(path, state) die( f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" @@ -1809,11 +1827,11 @@ def _remember(exit_code: int) -> None: # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 # 上限へ永久に到達せず修正と再レビューを往復し続ける。 _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) info("差し戻しの上限に達したため、変更要求として扱います") sys.exit(2) - _remember(3) + remember(3) statefile.save(path, state) info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") sys.exit(3) @@ -1821,12 +1839,12 @@ def _remember(exit_code: int) -> None: for item_id in entry["apply"]["applied"]: _find_item(state, item_id)["status"] = "done" state["phase"] = "propose" - _remember(0) + remember(0) statefile.save(path, state) info("✅ レビュー担当 2 者とも承認しました") return _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) open_findings = sum(1 for f in record["findings"] if not f["resolved"]) info(f"変更要求があります(未解決の指摘 {open_findings} 件)") From c102ec7de198b47db3a40487479b64dee707b169 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:25:42 +0000 Subject: [PATCH 40/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-review=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当のラウンド集計を _aggregate_impl_round に切り出し、aggregate の走査処理から分離した。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index c2b576b..0edaaf9 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -69,29 +69,8 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: "実装担当の集計から分離する" ) - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") - ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) - reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 + _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): @@ -137,6 +116,38 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: } +def _aggregate_impl_round( + impl: dict[str, dict[str, Any]], + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl_runtime: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From 061989119abb33c622d7e9448c375b22e74a6884 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:26:33 +0000 Subject: [PATCH 41/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-review=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュー担当ごとの指標更新を _aggregate_reviewer_round に切り出し、担当別集計を独立させた。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 0edaaf9..1ce6600 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -85,29 +85,7 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: f"round {round_no}: {name} が既定モデル(auto)で動いたため、" "レビュー担当の集計から分離する" ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, @@ -148,6 +126,38 @@ def _aggregate_impl_round( bucket["first_review_approved"] += 1 if approved_first else 0 +def _aggregate_reviewer_round( + reviewer: dict[str, dict[str, Any]], + entry: dict[str, Any], + name: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + rb = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From 5deba1dc827acb075a29718de337911e8718a02b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:27:18 +0000 Subject: [PATCH 42/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-review=20metrics.py#aggregate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit モデル不一致と auto モデルの unmeasured 追記を _append_model_measurement_warnings に集約した。 Item-Id: R1-004 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/scripts/lib/metrics.py | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py index 1ce6600..f3fb139 100644 --- a/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-shared/skills/cross-review/scripts/lib/metrics.py @@ -60,14 +60,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: requested = impl_model.get("requested") observed = impl_model.get("observed") - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) + _append_model_measurement_warnings( + unmeasured, round_no, impl_runtime, requested, observed, "実装担当" + ) reviews = _round_reviews(entry) _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) @@ -77,14 +72,9 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: spec = reviewer_models.get(name) or {} r_requested = spec.get("requested") r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) + _append_model_measurement_warnings( + unmeasured, round_no, name, r_requested, r_observed, "レビュー担当" + ) _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) return { @@ -158,6 +148,24 @@ def _aggregate_reviewer_round( ) +def _append_model_measurement_warnings( + unmeasured: list[str], + round_no: Any, + runtime: str, + requested: Optional[str], + observed: Optional[str], + role_label: str, +) -> None: + warning = _models.mismatch_warning(runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(runtime, requested): + unmeasured.append( + f"round {round_no}: {runtime} が既定モデル(auto)で動いたため、" + f"{role_label}の集計から分離する" + ) + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From c6d6edf8ef967feb48eede41c711b7c9800a37e7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:36:15 +0000 Subject: [PATCH 43/90] =?UTF-8?q?Chore:=20=E7=94=9F=E6=88=90=E7=89=A9?= =?UTF-8?q?=E3=82=92=E5=90=8C=E6=9C=9F=E3=81=99=E3=82=8B=EF=BC=88cross-ref?= =?UTF-8?q?actoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当は対象範囲だけを変更するため、生成物が同期されない。 同期を検査する pre-push を持つリポジトリでも push できるよう、 公開の直前に進行側がまとめて生成する。 --- .../cross-refactoring/scripts/refactor.py | 371 +++++++++++------- .../cross-review/scripts/lib/metrics.py | 149 ++++--- .../cross-refactoring/scripts/refactor.py | 371 +++++++++++------- .../cross-review/scripts/lib/metrics.py | 149 ++++--- .../cross-refactoring/scripts/refactor.py | 371 +++++++++++------- .../cross-review/scripts/lib/metrics.py | 149 ++++--- 6 files changed, 954 insertions(+), 606 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index b3cae92..8bce5f3 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -35,7 +35,7 @@ import subprocess import sys import time -from typing import Any, Iterable, Optional +from typing import Any, Callable, Iterable, Optional sys.path.insert( 0, @@ -1294,6 +1294,68 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) return + payload, work, head_branch, test_command, head_sha, ordered_range, in_range = ( + _load_apply_context(path, state, entry, args) + ) + + reported, unknown_ids = _collect_apply_reports(payload, entry) + + _validate_apply_commit_ownership( + path, state, entry, args, reported, unknown_ids, work, + ordered_range, in_range, head_sha, + ) + + applied, failed = _verify_apply_items( + path, state, entry, args, reported, work, in_range, test_command, head_branch, + ) + + entry["apply"] = { + "applied": applied, + "failed": failed, + # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") + ) + state["phase"] = "review" if applied else "propose" + + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if args.dry_run: + if failed: + _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: + # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 + # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 + entry["apply"]["merged_at"] = statefile.now() + entry["pending_push"] = True + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) + + +def _load_apply_context( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, +) -> tuple[dict[str, Any], pathlib.Path, str, str, str, list[str], set[str]]: impl = entry["impl"] result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) payload = _read_result(result, impl) @@ -1336,7 +1398,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "検証できない適用は採りません", code=2, ) + return payload, work, head_branch, test_command, head_sha, ordered_range, in_range + +def _collect_apply_reports( + payload: dict[str, Any], + entry: dict[str, Any], +) -> tuple[dict[str, dict[str, Any]], list[str]]: # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 @@ -1355,7 +1423,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported[item_id] = r elif item_id is not None: unknown_ids.append(str(item_id)) - + return reported, unknown_ids + + +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1377,71 +1459,85 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: owner_of.setdefault(full, item_id) unassigned = sorted(in_range - set(owner_of)) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" + if not (unassigned or unknown_ids or duplicated): + return + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - whole_round = { - "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: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 - # 残さないと同じ提案が次のラウンドで再び採用される。 - _defer_abandoned_items(state, entry) - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + whole_round = { + "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: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 + # 残さないと同じ提案が次のラウンドで再び採用される。 + _defer_abandoned_items(state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + +def _verify_apply_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + work: pathlib.Path, + in_range: set[str], + test_command: str, + head_branch: str, +) -> tuple[list[str], list[str]]: applied: list[str] = [] failed: list[str] = [] scope = state.get("target_scope") or [] @@ -1487,46 +1583,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: }) if not args.dry_run: statefile.save(path, state) - - entry["apply"] = { - "applied": applied, - "failed": failed, - # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに - # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 - # 検証を通っていない変更が Pull Request に残り続ける。 - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") - ) - state["phase"] = "review" if applied else "propose" - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - _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: - # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 - # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 - entry["apply"]["merged_at"] = statefile.now() - entry["pending_push"] = True - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) + return applied, failed def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: @@ -1702,38 +1759,9 @@ def cmd_judge_review(args: argparse.Namespace) -> None: sys.exit(seen["exit"]) return - verdict, problems = judge(reviews, reviewers, entry["items"]) - if post_problems: - verdict = "invalid" - problems = problems + post_problems - - # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも - # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 - record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} - for name in reviewers: - review = reviews.get(name) - review = review if isinstance(review, dict) else {} - record[name] = review.get("verdict") - findings = review.get("findings") - for finding in findings if isinstance(findings, list) else []: - if not isinstance(finding, dict): - continue - record["findings"].append({ - "reviewer": name, - "item_id": finding.get("item_id"), - "thread_id": finding.get("thread_id"), - "summary": finding.get("summary"), - "resolved": bool(finding.get("resolved")), - }) - entry["reviews"].append(record) - # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 - # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 - per_reviewer = entry.setdefault("reviewer_seconds", {}) - for name in reviewers: - review = reviews.get(name) - elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 - per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) - entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + verdict, problems, record = _aggregate_review_results( + entry, reviewers, reviews, post_problems + ) statefile.save(path, state) def _remember(exit_code: int) -> None: @@ -1741,6 +1769,24 @@ def _remember(exit_code: int) -> None: {"key": review_key, "exit": exit_code} ) + _handle_review_verdict( + path, state, entry, reviewers, reviews, unposted, + verdict, problems, record, _remember, + ) + + +def _handle_review_verdict( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + unposted: list[str], + verdict: str, + problems: list[str], + record: dict[str, Any], + remember: Callable[[int], None], +) -> None: if verdict == "invalid": for p in problems: info(f"❌ {p}") @@ -1755,7 +1801,7 @@ def _remember(exit_code: int) -> None: # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 blocked = missing + [name for name in unposted if name not in missing] if blocked: - _remember(ABORT) + remember(ABORT) statefile.save(path, state) die( f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" @@ -1781,11 +1827,11 @@ def _remember(exit_code: int) -> None: # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 # 上限へ永久に到達せず修正と再レビューを往復し続ける。 _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) info("差し戻しの上限に達したため、変更要求として扱います") sys.exit(2) - _remember(3) + remember(3) statefile.save(path, state) info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") sys.exit(3) @@ -1793,18 +1839,59 @@ def _remember(exit_code: int) -> None: for item_id in entry["apply"]["applied"]: _find_item(state, item_id)["status"] = "done" state["phase"] = "propose" - _remember(0) + remember(0) statefile.save(path, state) info("✅ レビュー担当 2 者とも承認しました") return _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) open_findings = sum(1 for f in record["findings"] if not f["resolved"]) info(f"変更要求があります(未解決の指摘 {open_findings} 件)") sys.exit(2) +def _aggregate_review_results( + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + post_problems: list[str], +) -> tuple[str, list[str], dict[str, Any]]: + verdict, problems = judge(reviews, reviewers, entry["items"]) + if post_problems: + verdict = "invalid" + problems = problems + post_problems + + # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも + # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 + record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} + for name in reviewers: + review = reviews.get(name) + review = review if isinstance(review, dict) else {} + record[name] = review.get("verdict") + findings = review.get("findings") + for finding in findings if isinstance(findings, list) else []: + if not isinstance(finding, dict): + continue + record["findings"].append({ + "reviewer": name, + "item_id": finding.get("item_id"), + "thread_id": finding.get("thread_id"), + "summary": finding.get("summary"), + "resolved": bool(finding.get("resolved")), + }) + entry["reviews"].append(record) + # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 + # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 + per_reviewer = entry.setdefault("reviewer_seconds", {}) + for name in reviewers: + review = reviews.get(name) + elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 + per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) + entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + return verdict, problems, record + + def cmd_should_abandon(args: argparse.Namespace) -> None: """Step 6 — 修正ラウンドの上限に達したか。 diff --git a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py index c2b576b..f3fb139 100644 --- a/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-claude/skills/cross-review/scripts/lib/metrics.py @@ -60,75 +60,22 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: requested = impl_model.get("requested") observed = impl_model.get("observed") - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") + _append_model_measurement_warnings( + unmeasured, round_no, impl_runtime, requested, observed, "実装担当" ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 + _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} r_requested = spec.get("requested") r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _append_model_measurement_warnings( + unmeasured, round_no, name, r_requested, r_observed, "レビュー担当" + ) + _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, @@ -137,6 +84,88 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: } +def _aggregate_impl_round( + impl: dict[str, dict[str, Any]], + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl_runtime: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + +def _aggregate_reviewer_round( + reviewer: dict[str, dict[str, Any]], + entry: dict[str, Any], + name: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + rb = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + +def _append_model_measurement_warnings( + unmeasured: list[str], + round_no: Any, + runtime: str, + requested: Optional[str], + observed: Optional[str], + role_label: str, +) -> None: + warning = _models.mismatch_warning(runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(runtime, requested): + unmeasured.append( + f"round {round_no}: {runtime} が既定モデル(auto)で動いたため、" + f"{role_label}の集計から分離する" + ) + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index b3cae92..8bce5f3 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -35,7 +35,7 @@ import subprocess import sys import time -from typing import Any, Iterable, Optional +from typing import Any, Callable, Iterable, Optional sys.path.insert( 0, @@ -1294,6 +1294,68 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) return + payload, work, head_branch, test_command, head_sha, ordered_range, in_range = ( + _load_apply_context(path, state, entry, args) + ) + + reported, unknown_ids = _collect_apply_reports(payload, entry) + + _validate_apply_commit_ownership( + path, state, entry, args, reported, unknown_ids, work, + ordered_range, in_range, head_sha, + ) + + applied, failed = _verify_apply_items( + path, state, entry, args, reported, work, in_range, test_command, head_branch, + ) + + entry["apply"] = { + "applied": applied, + "failed": failed, + # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") + ) + state["phase"] = "review" if applied else "propose" + + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if args.dry_run: + if failed: + _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: + # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 + # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 + entry["apply"]["merged_at"] = statefile.now() + entry["pending_push"] = True + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) + + +def _load_apply_context( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, +) -> tuple[dict[str, Any], pathlib.Path, str, str, str, list[str], set[str]]: impl = entry["impl"] result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) payload = _read_result(result, impl) @@ -1336,7 +1398,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "検証できない適用は採りません", code=2, ) + return payload, work, head_branch, test_command, head_sha, ordered_range, in_range + +def _collect_apply_reports( + payload: dict[str, Any], + entry: dict[str, Any], +) -> tuple[dict[str, dict[str, Any]], list[str]]: # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 @@ -1355,7 +1423,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported[item_id] = r elif item_id is not None: unknown_ids.append(str(item_id)) - + return reported, unknown_ids + + +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1377,71 +1459,85 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: owner_of.setdefault(full, item_id) unassigned = sorted(in_range - set(owner_of)) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" + if not (unassigned or unknown_ids or duplicated): + return + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - whole_round = { - "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: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 - # 残さないと同じ提案が次のラウンドで再び採用される。 - _defer_abandoned_items(state, entry) - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + whole_round = { + "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: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 + # 残さないと同じ提案が次のラウンドで再び採用される。 + _defer_abandoned_items(state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + +def _verify_apply_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + work: pathlib.Path, + in_range: set[str], + test_command: str, + head_branch: str, +) -> tuple[list[str], list[str]]: applied: list[str] = [] failed: list[str] = [] scope = state.get("target_scope") or [] @@ -1487,46 +1583,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: }) if not args.dry_run: statefile.save(path, state) - - entry["apply"] = { - "applied": applied, - "failed": failed, - # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに - # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 - # 検証を通っていない変更が Pull Request に残り続ける。 - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") - ) - state["phase"] = "review" if applied else "propose" - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - _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: - # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 - # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 - entry["apply"]["merged_at"] = statefile.now() - entry["pending_push"] = True - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) + return applied, failed def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: @@ -1702,38 +1759,9 @@ def cmd_judge_review(args: argparse.Namespace) -> None: sys.exit(seen["exit"]) return - verdict, problems = judge(reviews, reviewers, entry["items"]) - if post_problems: - verdict = "invalid" - problems = problems + post_problems - - # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも - # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 - record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} - for name in reviewers: - review = reviews.get(name) - review = review if isinstance(review, dict) else {} - record[name] = review.get("verdict") - findings = review.get("findings") - for finding in findings if isinstance(findings, list) else []: - if not isinstance(finding, dict): - continue - record["findings"].append({ - "reviewer": name, - "item_id": finding.get("item_id"), - "thread_id": finding.get("thread_id"), - "summary": finding.get("summary"), - "resolved": bool(finding.get("resolved")), - }) - entry["reviews"].append(record) - # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 - # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 - per_reviewer = entry.setdefault("reviewer_seconds", {}) - for name in reviewers: - review = reviews.get(name) - elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 - per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) - entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + verdict, problems, record = _aggregate_review_results( + entry, reviewers, reviews, post_problems + ) statefile.save(path, state) def _remember(exit_code: int) -> None: @@ -1741,6 +1769,24 @@ def _remember(exit_code: int) -> None: {"key": review_key, "exit": exit_code} ) + _handle_review_verdict( + path, state, entry, reviewers, reviews, unposted, + verdict, problems, record, _remember, + ) + + +def _handle_review_verdict( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + unposted: list[str], + verdict: str, + problems: list[str], + record: dict[str, Any], + remember: Callable[[int], None], +) -> None: if verdict == "invalid": for p in problems: info(f"❌ {p}") @@ -1755,7 +1801,7 @@ def _remember(exit_code: int) -> None: # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 blocked = missing + [name for name in unposted if name not in missing] if blocked: - _remember(ABORT) + remember(ABORT) statefile.save(path, state) die( f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" @@ -1781,11 +1827,11 @@ def _remember(exit_code: int) -> None: # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 # 上限へ永久に到達せず修正と再レビューを往復し続ける。 _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) info("差し戻しの上限に達したため、変更要求として扱います") sys.exit(2) - _remember(3) + remember(3) statefile.save(path, state) info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") sys.exit(3) @@ -1793,18 +1839,59 @@ def _remember(exit_code: int) -> None: for item_id in entry["apply"]["applied"]: _find_item(state, item_id)["status"] = "done" state["phase"] = "propose" - _remember(0) + remember(0) statefile.save(path, state) info("✅ レビュー担当 2 者とも承認しました") return _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) open_findings = sum(1 for f in record["findings"] if not f["resolved"]) info(f"変更要求があります(未解決の指摘 {open_findings} 件)") sys.exit(2) +def _aggregate_review_results( + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + post_problems: list[str], +) -> tuple[str, list[str], dict[str, Any]]: + verdict, problems = judge(reviews, reviewers, entry["items"]) + if post_problems: + verdict = "invalid" + problems = problems + post_problems + + # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも + # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 + record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} + for name in reviewers: + review = reviews.get(name) + review = review if isinstance(review, dict) else {} + record[name] = review.get("verdict") + findings = review.get("findings") + for finding in findings if isinstance(findings, list) else []: + if not isinstance(finding, dict): + continue + record["findings"].append({ + "reviewer": name, + "item_id": finding.get("item_id"), + "thread_id": finding.get("thread_id"), + "summary": finding.get("summary"), + "resolved": bool(finding.get("resolved")), + }) + entry["reviews"].append(record) + # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 + # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 + per_reviewer = entry.setdefault("reviewer_seconds", {}) + for name in reviewers: + review = reviews.get(name) + elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 + per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) + entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + return verdict, problems, record + + def cmd_should_abandon(args: argparse.Namespace) -> None: """Step 6 — 修正ラウンドの上限に達したか。 diff --git a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py index c2b576b..f3fb139 100644 --- a/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-codex/skills/cross-review/scripts/lib/metrics.py @@ -60,75 +60,22 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: requested = impl_model.get("requested") observed = impl_model.get("observed") - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") + _append_model_measurement_warnings( + unmeasured, round_no, impl_runtime, requested, observed, "実装担当" ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 + _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} r_requested = spec.get("requested") r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _append_model_measurement_warnings( + unmeasured, round_no, name, r_requested, r_observed, "レビュー担当" + ) + _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, @@ -137,6 +84,88 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: } +def _aggregate_impl_round( + impl: dict[str, dict[str, Any]], + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl_runtime: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + +def _aggregate_reviewer_round( + reviewer: dict[str, dict[str, Any]], + entry: dict[str, Any], + name: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + rb = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + +def _append_model_measurement_warnings( + unmeasured: list[str], + round_no: Any, + runtime: str, + requested: Optional[str], + observed: Optional[str], + role_label: str, +) -> None: + warning = _models.mismatch_warning(runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(runtime, requested): + unmeasured.append( + f"round {round_no}: {runtime} が既定モデル(auto)で動いたため、" + f"{role_label}の集計から分離する" + ) + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index b3cae92..8bce5f3 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -35,7 +35,7 @@ import subprocess import sys import time -from typing import Any, Iterable, Optional +from typing import Any, Callable, Iterable, Optional sys.path.insert( 0, @@ -1294,6 +1294,68 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: sys.exit(2) return + payload, work, head_branch, test_command, head_sha, ordered_range, in_range = ( + _load_apply_context(path, state, entry, args) + ) + + reported, unknown_ids = _collect_apply_reports(payload, entry) + + _validate_apply_commit_ownership( + path, state, entry, args, reported, unknown_ids, work, + ordered_range, in_range, head_sha, + ) + + applied, failed = _verify_apply_items( + path, state, entry, args, reported, work, in_range, test_command, head_branch, + ) + + entry["apply"] = { + "applied": applied, + "failed": failed, + # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 + "base_sha": entry.get("apply_base_sha"), + "head_sha": head_sha, + # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに + # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 + # 検証を通っていない変更が Pull Request に残り続ける。 + "merged_at": None, + } + entry.setdefault("durations", {})["apply"] = _safe_int( + payload.get("elapsed_seconds") + ) + state["phase"] = "review" if applied else "propose" + + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if args.dry_run: + if failed: + _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: + # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 + # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 + entry["apply"]["merged_at"] = statefile.now() + entry["pending_push"] = True + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + + if not applied: + info("全項目が失敗したため、このラウンドのレビューは行いません") + sys.exit(2) + + +def _load_apply_context( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, +) -> tuple[dict[str, Any], pathlib.Path, str, str, str, list[str], set[str]]: impl = entry["impl"] result = _result_path(state, impl, stem_for(impl, "apply", state["id"], args.round)) payload = _read_result(result, impl) @@ -1336,7 +1398,13 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: "検証できない適用は採りません", code=2, ) + return payload, work, head_branch, test_command, head_sha, ordered_range, in_range + +def _collect_apply_reports( + payload: dict[str, Any], + entry: dict[str, Any], +) -> tuple[dict[str, dict[str, Any]], list[str]]: # 申告は**このラウンドの改善項目のものだけ**を採る。架空の項目 ID へ割り当てられた # コミットを数に入れると、割り当て済みに見えるのに項目別の検証にも入らず、 # そのまま Pull Request に残せてしまう。 @@ -1355,7 +1423,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: reported[item_id] = r elif item_id is not None: unknown_ids.append(str(item_id)) - + return reported, unknown_ids + + +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 @@ -1377,71 +1459,85 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: owner_of.setdefault(full, item_id) unassigned = sorted(in_range - set(owner_of)) - if unassigned or unknown_ids or duplicated: - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" + if not (unassigned or unknown_ids or duplicated): + return + + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason - # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が - # git の履歴から決め直す。 - whole_round = { - "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: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - entry["apply"] = { - "applied": [], "failed": list(entry["items"]), - "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, - "unassigned_commits": unassigned, - "unknown_item_ids": unknown_ids, - "duplicated_commits": duplicated, - "merged_at": statefile.now(), - } - state["phase"] = "propose" - if args.dry_run: - info("(dry-run)状態ファイルは更新していません") - else: - # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 - # 残さないと同じ提案が次のラウンドで再び採用される。 - _defer_abandoned_items(state, entry) - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - sys.exit(2) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + reason = ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 + # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が + # git の履歴から決め直す。 + whole_round = { + "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: + # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 + # 取り消しコミット自体を「未割当」として再び戻すことがない。 + entry["apply_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + entry["apply"] = { + "applied": [], "failed": list(entry["items"]), + "base_sha": entry.get("apply_base_sha"), "head_sha": head_sha, + "unassigned_commits": unassigned, + "unknown_item_ids": unknown_ids, + "duplicated_commits": duplicated, + "merged_at": statefile.now(), + } + state["phase"] = "propose" + if args.dry_run: + info("(dry-run)状態ファイルは更新していません") + else: + # 項目別の失敗と同じく、**ここで取り消した項目も「対象外」に残す**。 + # 残さないと同じ提案が次のラウンドで再び採用される。 + _defer_abandoned_items(state, entry) + statefile.save(path, state) + _push_head(state) + entry["pending_push"] = False + statefile.save(path, state) + sys.exit(2) + +def _verify_apply_items( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + work: pathlib.Path, + in_range: set[str], + test_command: str, + head_branch: str, +) -> tuple[list[str], list[str]]: applied: list[str] = [] failed: list[str] = [] scope = state.get("target_scope") or [] @@ -1487,46 +1583,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: }) if not args.dry_run: statefile.save(path, state) - - entry["apply"] = { - "applied": applied, - "failed": failed, - # 起点はオーケストレータが記録したもの。申告は記録にも残さない。 - "base_sha": entry.get("apply_base_sha"), - "head_sha": head_sha, - # **取り込み済みの印は最後に立てる。** 取り消しより先に立てると、取り消しに - # 失敗して中断したときに、次の実行が処理済みガードで素通りしてしまい、 - # 検証を通っていない変更が Pull Request に残り続ける。 - "merged_at": None, - } - entry.setdefault("durations", {})["apply"] = _safe_int( - payload.get("elapsed_seconds") - ) - state["phase"] = "review" if applied else "propose" - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - _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: - # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 - # ここで公開しないとレビュー担当が Pull Request 上の差分へ指摘を書けない。 - entry["apply"]["merged_at"] = statefile.now() - entry["pending_push"] = True - statefile.save(path, state) - _push_head(state) - entry["pending_push"] = False - statefile.save(path, state) - - if not applied: - info("全項目が失敗したため、このラウンドのレビューは行いません") - sys.exit(2) + return applied, failed def _defer_abandoned_items(state: dict[str, Any], entry: dict[str, Any]) -> None: @@ -1702,38 +1759,9 @@ def cmd_judge_review(args: argparse.Namespace) -> None: sys.exit(seen["exit"]) return - verdict, problems = judge(reviews, reviewers, entry["items"]) - if post_problems: - verdict = "invalid" - problems = problems + post_problems - - # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも - # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 - record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} - for name in reviewers: - review = reviews.get(name) - review = review if isinstance(review, dict) else {} - record[name] = review.get("verdict") - findings = review.get("findings") - for finding in findings if isinstance(findings, list) else []: - if not isinstance(finding, dict): - continue - record["findings"].append({ - "reviewer": name, - "item_id": finding.get("item_id"), - "thread_id": finding.get("thread_id"), - "summary": finding.get("summary"), - "resolved": bool(finding.get("resolved")), - }) - entry["reviews"].append(record) - # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 - # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 - per_reviewer = entry.setdefault("reviewer_seconds", {}) - for name in reviewers: - review = reviews.get(name) - elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 - per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) - entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + verdict, problems, record = _aggregate_review_results( + entry, reviewers, reviews, post_problems + ) statefile.save(path, state) def _remember(exit_code: int) -> None: @@ -1741,6 +1769,24 @@ def _remember(exit_code: int) -> None: {"key": review_key, "exit": exit_code} ) + _handle_review_verdict( + path, state, entry, reviewers, reviews, unposted, + verdict, problems, record, _remember, + ) + + +def _handle_review_verdict( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + unposted: list[str], + verdict: str, + problems: list[str], + record: dict[str, Any], + remember: Callable[[int], None], +) -> None: if verdict == "invalid": for p in problems: info(f"❌ {p}") @@ -1755,7 +1801,7 @@ def _remember(exit_code: int) -> None: # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 blocked = missing + [name for name in unposted if name not in missing] if blocked: - _remember(ABORT) + remember(ABORT) statefile.save(path, state) die( f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" @@ -1781,11 +1827,11 @@ def _remember(exit_code: int) -> None: # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 # 上限へ永久に到達せず修正と再レビューを往復し続ける。 _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) info("差し戻しの上限に達したため、変更要求として扱います") sys.exit(2) - _remember(3) + remember(3) statefile.save(path, state) info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") sys.exit(3) @@ -1793,18 +1839,59 @@ def _remember(exit_code: int) -> None: for item_id in entry["apply"]["applied"]: _find_item(state, item_id)["status"] = "done" state["phase"] = "propose" - _remember(0) + remember(0) statefile.save(path, state) info("✅ レビュー担当 2 者とも承認しました") return _prepare_fix_phase(state, entry) - _remember(2) + remember(2) statefile.save(path, state) open_findings = sum(1 for f in record["findings"] if not f["resolved"]) info(f"変更要求があります(未解決の指摘 {open_findings} 件)") sys.exit(2) +def _aggregate_review_results( + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + post_problems: list[str], +) -> tuple[str, list[str], dict[str, Any]]: + verdict, problems = judge(reviews, reviewers, entry["items"]) + if post_problems: + verdict = "invalid" + problems = problems + post_problems + + # 記録も**型検査済みの値だけ**で作る。`judge()` が invalid と判定した入力でも + # ここを通るため、無条件に `.get()` を呼ぶと差し戻す前に落ちる。 + record: dict[str, Any] = {"round": len(entry["reviews"]) + 1, "findings": []} + for name in reviewers: + review = reviews.get(name) + review = review if isinstance(review, dict) else {} + record[name] = review.get("verdict") + findings = review.get("findings") + for finding in findings if isinstance(findings, list) else []: + if not isinstance(finding, dict): + continue + record["findings"].append({ + "reviewer": name, + "item_id": finding.get("item_id"), + "thread_id": finding.get("thread_id"), + "summary": finding.get("summary"), + "resolved": bool(finding.get("resolved")), + }) + entry["reviews"].append(record) + # レビュー担当ごとの所要時間は**別々に**持つ。ラウンドの合計を各担当へ配ると、 + # 2 者分を両方に数えることになり、担当同士の比較が成り立たない。 + per_reviewer = entry.setdefault("reviewer_seconds", {}) + for name in reviewers: + review = reviews.get(name) + elapsed = review.get("elapsed_seconds") if isinstance(review, dict) else 0 + per_reviewer[name] = per_reviewer.get(name, 0) + _safe_int(elapsed) + entry.setdefault("durations", {})["review"] = sum(per_reviewer.values()) + return verdict, problems, record + + def cmd_should_abandon(args: argparse.Namespace) -> None: """Step 6 — 修正ラウンドの上限に達したか。 diff --git a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py index c2b576b..f3fb139 100644 --- a/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py +++ b/plugins/ndf-kiro/skills/cross-review/scripts/lib/metrics.py @@ -60,75 +60,22 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: requested = impl_model.get("requested") observed = impl_model.get("observed") - warning = _models.mismatch_warning(impl_runtime, requested, observed) - if warning: - unmeasured.append(f"round {round_no}: {warning}") - if not _models.is_measurable(impl_runtime, requested): - unmeasured.append( - f"round {round_no}: {impl_runtime} が既定モデル(auto)で動いたため、" - "実装担当の集計から分離する" - ) - - bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) - bucket["rounds"] += 1 - bucket["seconds"] += _duration(entry, ("apply", "fix")) - - round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] - bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") - bucket["abandoned"] += sum( - 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} - ) - bucket["budget_exceeded"] += sum( - 1 for i in round_items if i.get("budget_exceeded") + _append_model_measurement_warnings( + unmeasured, round_no, impl_runtime, requested, observed, "実装担当" ) - bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) - bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) reviews = _round_reviews(entry) - if reviews: - first = reviews[0] - approved_first = all( - _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) - ) - bucket["first_review_total"] += 1 - bucket["first_review_approved"] += 1 if approved_first else 0 + _aggregate_impl_round(impl, entry, items_by_id, impl_runtime, requested, reviews) reviewer_models = entry.get("reviewer_models") or {} for name in entry.get("reviewers", []): spec = reviewer_models.get(name) or {} r_requested = spec.get("requested") r_observed = spec.get("observed") - r_warning = _models.mismatch_warning(name, r_requested, r_observed) - if r_warning: - unmeasured.append(f"round {round_no}: {r_warning}") - if not _models.is_measurable(name, r_requested): - unmeasured.append( - f"round {round_no}: {name} が既定モデル(auto)で動いたため、" - "レビュー担当の集計から分離する" - ) - rb = reviewer.setdefault(_key(name, r_requested), _new_reviewer_bucket()) - # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 - # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 - rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) - for review in reviews: - if _verdict(review, name) is None: - continue - rb["reviews"] += 1 - findings = [ - f for f in review.get("findings", []) - if isinstance(f, dict) and f.get("reviewer") == name - ] - rb["findings"] += len(findings) - rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _append_model_measurement_warnings( + unmeasured, round_no, name, r_requested, r_observed, "レビュー担当" + ) + _aggregate_reviewer_round(reviewer, entry, name, r_requested, reviews) return { "impl": {k: _finish_impl(v) for k, v in sorted(impl.items())}, @@ -137,6 +84,88 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: } +def _aggregate_impl_round( + impl: dict[str, dict[str, Any]], + entry: dict[str, Any], + items_by_id: dict[str, dict[str, Any]], + impl_runtime: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + bucket = impl.setdefault(_key(impl_runtime, requested), _new_impl_bucket()) + bucket["rounds"] += 1 + bucket["seconds"] += _duration(entry, ("apply", "fix")) + + round_items = [items_by_id[i] for i in entry.get("items", []) if i in items_by_id] + bucket["applied"] += sum(1 for i in round_items if i.get("status") == "done") + bucket["abandoned"] += sum( + 1 for i in round_items if i.get("status") in {"abandoned", "blocked"} + ) + bucket["budget_exceeded"] += sum( + 1 for i in round_items if i.get("budget_exceeded") + ) + bucket["test_failed"] += sum(1 for i in round_items if i.get("test_failed")) + bucket["fix_rounds"] += int(entry.get("fix_rounds") or 0) + + if reviews: + first = reviews[0] + approved_first = all( + _verdict(first, r) == "APPROVE" for r in entry.get("reviewers", []) + ) + bucket["first_review_total"] += 1 + bucket["first_review_approved"] += 1 if approved_first else 0 + + +def _aggregate_reviewer_round( + reviewer: dict[str, dict[str, Any]], + entry: dict[str, Any], + name: str, + requested: Optional[str], + reviews: list[dict[str, Any]], +) -> None: + rb = reviewer.setdefault(_key(name, requested), _new_reviewer_bucket()) + # 担当ごとの所要時間があればそれを使う。無ければ 0 のままにする。 + # ラウンドの合計を配ると 2 者分を両方に数えてしまい、比較が成り立たない。 + rb["seconds"] += float((entry.get("reviewer_seconds") or {}).get(name, 0)) + for review in reviews: + if _verdict(review, name) is None: + continue + rb["reviews"] += 1 + findings = [ + f for f in review.get("findings", []) + if isinstance(f, dict) and f.get("reviewer") == name + ] + rb["findings"] += len(findings) + rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) + + +def _append_model_measurement_warnings( + unmeasured: list[str], + round_no: Any, + runtime: str, + requested: Optional[str], + observed: Optional[str], + role_label: str, +) -> None: + warning = _models.mismatch_warning(runtime, requested, observed) + if warning: + unmeasured.append(f"round {round_no}: {warning}") + if not _models.is_measurable(runtime, requested): + unmeasured.append( + f"round {round_no}: {runtime} が既定モデル(auto)で動いたため、" + f"{role_label}の集計から分離する" + ) + + def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: durations = entry.get("durations") or {} return sum(float(durations.get(p) or 0) for p in phases) From b4c44d59a8bd9bc1cc7871ec24e7e110f3997064 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:46:02 +0000 Subject: [PATCH 44/90] =?UTF-8?q?Test:=20cmd=5Fmerge=5Ffix=20=E3=81=AE?= =?UTF-8?q?=E9=87=8D=E8=A4=87=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=A8?= =?UTF-8?q?=E6=9C=AA=E7=94=B3=E5=91=8A=E3=82=B3=E3=83=9F=E3=83=83=E3=83=88?= =?UTF-8?q?=E6=8B=92=E5=90=A6=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: R2-001 の抽出に先立ち、既存テストで確認できていなかった 2 経路 (同じ試行番号・同じ結果ファイルの重複取り込みの早期リターン、範囲内に 申告のないコミットがあるときの範囲ごと取り消し)を現状固定する。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../tests/test_abandon_items.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) 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 5101513..2ed8f26 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 @@ -387,6 +387,54 @@ def fake_run(cmd, **kw): assert any("cursor=C1" in "".join(c) for c in calls) +def test_merge_fix_skips_already_merged_result( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """同じ試行番号・同じ結果ファイルの内容は二重に取り込まない。 + + 叩き直しても鍵(試行番号 + 結果ファイルのハッシュ)が変わらなければ、 + fix_rounds を進めず即座に戻る。 + """ + state_path = _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + state = read_state(state_path) + result_path = state_path.parent / "codex-fix-r1-result.json" + import hashlib as _hashlib + merge_key = "1:" + _hashlib.sha256(result_path.read_bytes()).hexdigest() + state["rounds"][0]["fix_merged_keys"] = [merge_key] + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + monkeypatch.setattr(refactor, "resolved_threads_on_github", + lambda repo, pr: {"PRRT_a"}) + refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + after = read_state(state_path) + # fix_rounds は進まず、解決も反映されない(早期に戻ったため) + assert after["rounds"][0]["fix_rounds"] == 0 + assert after["rounds"][0]["reviews"][0]["findings"][0]["resolved"] is False + + +def test_merge_fix_rejects_commits_missing_from_the_claim( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """範囲に含まれるが誰の申告にも無いコミットがあれば、範囲ごと取り消す。""" + state_path = _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + monkeypatch.setattr(refactor, "resolved_threads_on_github", + lambda repo, pr: {"PRRT_a"}) + # 範囲には fix111 の他に申告されていない stray999 が含まれる + monkeypatch.setattr( + refactor, "commits_in_range", + lambda work, base, head: ["fix111", "stray999"], + ) + calls = _no_git(refactor, monkeypatch) + refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + state = read_state(state_path) + assert state["rounds"][0]["reviews"][0]["findings"][0]["resolved"] is False + assert "fix111" not in state["items"][0]["commits"] + reverted = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + assert "stray999" in reverted and "fix111" in reverted + assert any(c[:2] == ["git", "push"] for c in calls) + + @pytest.mark.parametrize("broken_ids", ["文字列", 123, True, {"a": 1}]) def test_broken_fix_result_does_not_crash( refactor, tmp_path, env_tmp_dir, monkeypatch, broken_ids From 39eadd8c11c55e34f0e20fb3774536bfecb8da79 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:46:57 +0000 Subject: [PATCH 45/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重複取り込みの判定(試行番号と結果ファイルのハッシュから鍵を作り、既知の 鍵なら早期に戻る)を _fix_merge_key / _already_merged_fix_result として 抽出した。cmd_merge_fix 側は鍵の作成と判定の呼び出しだけを残す。振る舞いは 変えていない。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 8bce5f3..6a3f175 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1979,6 +1979,43 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) +def _fix_merge_key(entry: dict[str, Any], result: pathlib.Path) -> str: + """修正結果の取り込み済み判定に使う鍵を作る。 + + **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 + 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 + 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 + 鍵は**試行番号と結果ファイルの内容**から作る。 + + - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず + 同じ申告を再処理してしまう。 + - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ + 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま + 同じ修正を起動し続ける。 + - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に + なりうる。 + + 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が + **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 + """ + attempt = entry.get("fix_attempts", 0) + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def _already_merged_fix_result( + entry: dict[str, Any], merge_key: str +) -> bool: + """この修正結果を取り込み済みなら真を返し、鍵の一覧を更新する。""" + merged_keys = entry.setdefault("fix_merged_keys", []) + if merge_key in merged_keys: + info( + f"↻ この修正結果は取り込み済みです" + f"(修正ラウンド {entry['fix_rounds']})" + ) + return True + return False + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -1989,35 +2026,12 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) payload = _read_result(result, impl) - # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 - # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 - # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 - # 鍵は**試行番号と結果ファイルの内容**から作る。 - # - # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず - # 同じ申告を再処理してしまう。 - # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ - # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま - # 同じ修正を起動し続ける。 - # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に - # なりうる。 - # - # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が - # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 work = state["worktrees"]["work"] head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" - attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) - merged_keys = entry.setdefault("fix_merged_keys", []) - if merge_key in merged_keys: - info( - f"↻ この修正結果は取り込み済みです" - f"(修正ラウンド {entry['fix_rounds']})" - ) + merge_key = _fix_merge_key(entry, result) + if _already_merged_fix_result(entry, merge_key): return + merged_keys = entry["fix_merged_keys"] # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の From ea5cc93ed6588ee116acbdaa4b271f517d87019c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:48:04 +0000 Subject: [PATCH 46/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解決スレッドの自己申告と GitHub 側の isResolved の突き合わせを _resolved_fix_thread_ids として抽出した。payload と repo/pr だけに依存する 独立した判断であり、他の段(取り込み済み判定・コミット検証)から分離 できる。振る舞いは変えていない。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 51 +++++++++++-------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6a3f175..5638dec 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2016,6 +2016,34 @@ def _already_merged_fix_result( return False +def _resolved_fix_thread_ids(payload: dict[str, Any], repo: str, pr: int) -> set[str]: + """自己申告と GitHub 側の解決状態を突き合わせ、両方が解決と言う ID だけ返す。 + + 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + """ + raw_claimed = payload.get("resolved_thread_ids") + # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 + # **配列であることを先に確かめる。** + claimed = { + t for t in (raw_claimed if isinstance(raw_claimed, list) else []) + if isinstance(t, str) and t.strip() + } + if raw_claimed is not None and not isinstance(raw_claimed, list): + info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" + "解決の申告は無かったものとして扱います") + actual = resolved_threads_on_github(repo, pr) + if actual is None: + info("⚠ レビュースレッドの解決状態を取得できませんでした。" + "自己申告は採用せず、未解決のまま扱います") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2033,28 +2061,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: return merged_keys = entry["fix_merged_keys"] - # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 - raw_claimed = payload.get("resolved_thread_ids") - # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 - # **配列であることを先に確かめる。** - claimed = { - t for t in (raw_claimed if isinstance(raw_claimed, list) else []) - if isinstance(t, str) and t.strip() - } - if raw_claimed is not None and not isinstance(raw_claimed, list): - info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" - "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) - if actual is None: - info("⚠ レビュースレッドの解決状態を取得できませんでした。" - "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 From e8d00614769eadd0dd7884499c5a901d4b8a9d2e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:48:55 +0000 Subject: [PATCH 47/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 範囲内のコミットのうち申告に無いものを求める処理を _unassigned_fix_commits として抽出した。振る舞いは変えていない。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 5638dec..f12c12e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2044,6 +2044,23 @@ def _resolved_fix_thread_ids(payload: dict[str, Any], repo: str, pr: int) -> set return resolved +def _unassigned_fix_commits( + work: str, reported_shas: list[str], ordered_range: list[str] +) -> list[str]: + """範囲内のコミットのうち、どの申告にも含まれていないものを返す。 + + 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + """ + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + return sorted(set(ordered_range) - reported_full) + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2083,16 +2100,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: code=2, ) reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) + unassigned = _unassigned_fix_commits(work, reported_shas, ordered_range) facts = collect_commit_facts( work, reported_shas, set(ordered_range), From 9f84b639cb742ff00ee9e06355c84d3864bdf360 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:49:54 +0000 Subject: [PATCH 48/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit facts を verify_fix_commit に通して problems と accepted を作る処理を _verify_fix_commits として抽出した。これで cmd_merge_fix は取り込み済み 判定・解決スレッド照合・未申告コミット検出・コミット検証・状態反映の 5 段が、それぞれ名前の付いた関数呼び出しとして読める。振る舞いは変えて いない。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f12c12e..a98ccb0 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2061,6 +2061,29 @@ def _unassigned_fix_commits( return sorted(set(ordered_range) - reported_full) +def _verify_fix_commits( + facts: list[dict[str, Any]], scope: list[str] +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミットを検証し、問題点の一覧と受理した (item_id, sha) を返す。 + + **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** + 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける + (見送りの対象にもならない)。どのコミットが安全かは決められないので、 + 適用フェーズの未割当コミットと同じ扱いにする。 + """ + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return problems, accepted + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2108,20 +2131,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** - # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける - # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 - # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + problems, accepted = _verify_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( From d3518b451b2b7b37e096db011ce8cb7bc49fbd4b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:52:02 +0000 Subject: [PATCH 49/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fproposals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 各ランタイムの提案結果ファイルを読み込んで JSON を解析する処理を _load_runtime_proposals として抽出した。振る舞いは変えていない。 Item-Id: R2-002 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index a98ccb0..3fda37d 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1154,30 +1154,14 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 +def _load_runtime_proposals( + state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """各ランタイムの提案結果ファイルを読み込み、JSON を解析する。 - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + 1 者が結果ファイルを欠かした・壊れた JSON を返した場合も、その者の提案を + 無かったものとして扱い、全体の統合は続ける。 """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1206,6 +1190,34 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: proposals[runtime] = [i for i in items if isinstance(i, dict)] \ if isinstance(items, list) else [] entry["proposed"][runtime] = len(proposals[runtime]) + return proposals + + +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _load_runtime_proposals(state, entry) excluded = { (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] From 08e0cbf1e7002885a3b8844719e8f863af9561de Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:52:59 +0000 Subject: [PATCH 50/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#cmd=5Fmerge=5Fproposals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 統合済みの提案リストから状態オブジェクトを更新し、次回ラウンドの起点 (apply_base_sha)を準備する処理を _update_state_from_merged_proposals として抽出した。振る舞いは変えていない。 Item-Id: R2-002 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 77 +++++++++++-------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 3fda37d..45c5828 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1193,6 +1193,49 @@ def _load_runtime_proposals( return proposals +def _update_state_from_merged_proposals( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + adopted: list[dict[str, Any]], + deferred: list[dict[str, Any]], +) -> None: + """統合済みの提案から状態オブジェクトを更新し、次回ラウンドの起点を準備する。""" + # 収束判定に使う「前ラウンドとの重複率」。見送りも含めた提案全体で測る。 + current_keys = [(i["path"], i["symbol"], i["smell"]) for i in adopted + deferred] + entry["proposal_keys"] = [list(k) for k in current_keys] + entry["merged"] = len(current_keys) + entry["adopted"] = len(adopted) + entry["deferred"] = len(deferred) + + round_no = entry["round"] + for n, item in enumerate(adopted, start=1): + item_id = f"R{round_no}-{n:03d}" + state["items"].append({ + "item_id": item_id, + "round": round_no, + **item, + "status": "pending", + "commits": [], + }) + entry["items"].append(item_id) + for item in deferred: + state["deferred_items"].append({**item, "round": round_no}) + + # 適用の起点は**オーケストレータ側で**確定させる。実装担当の申告に委ねると、 + # 欠落・不正時に範囲検査が無効になり、過去の任意のコミットが実在扱いになる。 + # 提案は読むだけなので、この時点の HEAD が着手前の状態である。 + entry["apply_base_sha"] = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + + state["phase"] = "apply" if adopted else "converged" + if not adopted: + # 呼び出し側は終了コード 2 で繰り返しを抜けるため、`advance` を通らない。 + # 終了理由をここで確定させないと、報告が「未終了」のままになる。 + state["final"] = "no_more_proposals" + state["ended_at"] = statefile.now() + statefile.save(path, state) + + def cmd_merge_proposals(args: argparse.Namespace) -> None: """Step 3 — 提案をマージして改善項目を作る。 @@ -1229,39 +1272,7 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: excluded_keys=excluded, ) - # 収束判定に使う「前ラウンドとの重複率」。見送りも含めた提案全体で測る。 - current_keys = [(i["path"], i["symbol"], i["smell"]) for i in adopted + deferred] - entry["proposal_keys"] = [list(k) for k in current_keys] - entry["merged"] = len(current_keys) - entry["adopted"] = len(adopted) - entry["deferred"] = len(deferred) - - round_no = entry["round"] - for n, item in enumerate(adopted, start=1): - item_id = f"R{round_no}-{n:03d}" - state["items"].append({ - "item_id": item_id, - "round": round_no, - **item, - "status": "pending", - "commits": [], - }) - entry["items"].append(item_id) - for item in deferred: - state["deferred_items"].append({**item, "round": round_no}) - - # 適用の起点は**オーケストレータ側で**確定させる。実装担当の申告に委ねると、 - # 欠落・不正時に範囲検査が無効になり、過去の任意のコミットが実在扱いになる。 - # 提案は読むだけなので、この時点の HEAD が着手前の状態である。 - entry["apply_base_sha"] = _git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) - - state["phase"] = "apply" if adopted else "converged" - if not adopted: - # 呼び出し側は終了コード 2 で繰り返しを抜けるため、`advance` を通らない。 - # 終了理由をここで確定させないと、報告が「未終了」のままになる。 - state["final"] = "no_more_proposals" - state["ended_at"] = statefile.now() - statefile.save(path, state) + _update_state_from_merged_proposals(path, state, entry, adopted, deferred) info( f"提案 {sum(entry['proposed'].values())} 件 → 統合 {entry['merged']} 件 → " f"採用 {entry['adopted']} 件 / 見送り {entry['deferred']} 件" From 663bcb8855818a4204eebbef16f57e92d40c0747 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:54:07 +0000 Subject: [PATCH 51/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#=5Fvalidate=5Fapply=5Fcommit?= =?UTF-8?q?=5Fownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 申告コミットを完全 SHA へ正規化し、所有項目と重複申告を特定する処理を _detect_commit_owners として抽出した。振る舞いは変えていない。 Item-Id: R2-003 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 42 ++++++++++++------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 45c5828..d3a6d3e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1449,6 +1449,31 @@ def _collect_apply_reports( return reported, unknown_ids +def _detect_commit_owners( + work: pathlib.Path, reported: dict[str, dict[str, Any]] +) -> tuple[dict[str, str], list[str]]: + """申告コミットを完全 SHA へ正規化し、所有項目と重複申告を特定する。 + + **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + 実際の差分が食い違い、どちらが正しいか決められなくなる。 + + 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + """ + owner_of: dict[str, str] = {} + duplicated: list[str] = [] + for item_id, r in reported.items(): + for sha in _reported_shas(r): + full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) + if full is None: + continue # 実在しない申告は項目ごとの検証で落ちる + if full in owner_of and owner_of[full] != item_id: + duplicated.append(full) + owner_of.setdefault(full, item_id) + return owner_of, duplicated + + def _validate_apply_commit_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1464,22 +1489,7 @@ def _validate_apply_commit_ownership( # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 - owner_of: dict[str, str] = {} - duplicated: list[str] = [] - for item_id, r in reported.items(): - for sha in _reported_shas(r): - full = _git_out(work, ["rev-parse", "--verify", f"{sha}^{{commit}}"]) - if full is None: - continue # 実在しない申告は項目ごとの検証で落ちる - if full in owner_of and owner_of[full] != item_id: - duplicated.append(full) - owner_of.setdefault(full, item_id) + owner_of, duplicated = _detect_commit_owners(work, reported) unassigned = sorted(in_range - set(owner_of)) if not (unassigned or unknown_ids or duplicated): From b172345bbacc3685aeafc701eccff393c244ea85 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:55:01 +0000 Subject: [PATCH 52/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#=5Fvalidate=5Fapply=5Fcommit?= =?UTF-8?q?=5Fownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit エラーメッセージの組み立てを _build_ownership_error_reason として抽出 した。振る舞いは変えていない。 Item-Id: R2-003 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index d3a6d3e..78dea83 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1474,6 +1474,33 @@ def _detect_commit_owners( return owner_of, duplicated +def _build_ownership_error_reason( + unassigned: list[str], unknown_ids: list[str], duplicated: list[str] +) -> str: + """所有権検査の失敗理由を組み立てる。""" + causes = [] + if unassigned: + causes.append( + f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" + f"({', '.join(s[:7] for s in unassigned[:5])})" + ) + if unknown_ids: + causes.append( + f"このラウンドに無い改善項目 ID の申告" + f"({', '.join(unknown_ids[:5])})" + ) + if duplicated: + causes.append( + f"複数の項目が同じコミットを申告しています" + f"({', '.join(s[:7] for s in duplicated[:5])})" + ) + return ( + "、".join(causes) + + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" + "残さないため、ラウンドごと取り消します" + ) + + def _validate_apply_commit_ownership( path: pathlib.Path, state: dict[str, Any], @@ -1495,27 +1522,7 @@ def _validate_apply_commit_ownership( if not (unassigned or unknown_ids or duplicated): return - causes = [] - if unassigned: - causes.append( - f"どの改善項目にも割り当てられていないコミットが {len(unassigned)} 件" - f"({', '.join(s[:7] for s in unassigned[:5])})" - ) - if unknown_ids: - causes.append( - f"このラウンドに無い改善項目 ID の申告" - f"({', '.join(unknown_ids[:5])})" - ) - if duplicated: - causes.append( - f"複数の項目が同じコミットを申告しています" - f"({', '.join(s[:7] for s in duplicated[:5])})" - ) - reason = ( - "、".join(causes) - + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" - "残さないため、ラウンドごと取り消します" - ) + reason = _build_ownership_error_reason(unassigned, unknown_ids, duplicated) info(f"❌ {reason}") for item_id in entry["items"]: it = _find_item(state, item_id) From 5dc2ab1b84c25f34e243f51e13bd85fb6a4c3ea8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:56:07 +0000 Subject: [PATCH 53/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#=5Fvalidate=5Fapply=5Fcommit?= =?UTF-8?q?=5Fownership?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証失敗時の切り戻し(範囲全体の取り消し・状態更新・push)を _revert_unverified_apply_round として抽出した。sys.exit(2) は呼び出し元に 残し、切り戻し自体は状態更新と公開までを行う手続きとして分離した。 振る舞いは変えていない。 Item-Id: R2-003 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 57 ++++++++++++------- 1 file changed, 38 insertions(+), 19 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 78dea83..18d3792 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1501,33 +1501,19 @@ def _build_ownership_error_reason( ) -def _validate_apply_commit_ownership( +def _revert_unverified_apply_round( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], args: argparse.Namespace, - reported: dict[str, dict[str, Any]], - unknown_ids: list[str], work: pathlib.Path, ordered_range: list[str], - in_range: set[str], + unassigned: list[str], + unknown_ids: list[str], + duplicated: list[str], head_sha: str, ) -> None: - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - owner_of, duplicated = _detect_commit_owners(work, reported) - - unassigned = sorted(in_range - set(owner_of)) - if not (unassigned or unknown_ids or duplicated): - return - - reason = _build_ownership_error_reason(unassigned, unknown_ids, duplicated) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason + """検証を通らない適用ラウンドの範囲を取り消し、状態と公開を反映する。""" # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 @@ -1564,6 +1550,39 @@ def _validate_apply_commit_ownership( _push_head(state) entry["pending_push"] = False statefile.save(path, state) + + +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + owner_of, duplicated = _detect_commit_owners(work, reported) + + unassigned = sorted(in_range - set(owner_of)) + if not (unassigned or unknown_ids or duplicated): + return + + reason = _build_ownership_error_reason(unassigned, unknown_ids, duplicated) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + _revert_unverified_apply_round( + path, state, entry, args, work, ordered_range, + unassigned, unknown_ids, duplicated, head_sha, + ) sys.exit(2) From fe08565bbb083ace1ffcdcca4d90f4c846c6ed45 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:57:10 +0000 Subject: [PATCH 54/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#judge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review の存在と dict 型を確認する処理を _validate_review_payload として 抽出した。振る舞いは変えていない。 Item-Id: R2-004 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 18d3792..fc0a116 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -671,6 +671,19 @@ def _unposted_reviewers( return names, problems +def _validate_review_payload(name: str, review: Any) -> Optional[str]: + """review の存在と dict 型を確認する。 + + 出力の形が崩れていても落ちないようにする。相手は LLM なので、 + 期待した型で返ってこないことがある。崩れていたら差し戻す。 + """ + if not review: + return f"{name} のレビュー結果がありません" + if not isinstance(review, dict): + return f"{name} のレビュー結果が JSON オブジェクトではありません" + return None + + def judge( reviews: dict[str, dict[str, Any]], reviewers: list[str], round_items: list[str] ) -> tuple[str, list[str]]: @@ -683,16 +696,12 @@ def judge( そのラウンドに無い ID や欠落は判定に使えない。ラウンド全体に対する指摘は `null` を明示させ、取り消し時はラウンド全件の対象とする。 """ - # 出力の形が崩れていても落ちないようにする。相手は LLM なので、 - # 期待した型で返ってこないことがある。崩れていたら差し戻す。 problems: list[str] = [] for name in reviewers: review = reviews.get(name) - if not review: - problems.append(f"{name} のレビュー結果がありません") - continue - if not isinstance(review, dict): - problems.append(f"{name} のレビュー結果が JSON オブジェクトではありません") + payload_problem = _validate_review_payload(name, review) + if payload_problem: + problems.append(payload_problem) continue verdict = review.get("verdict") if verdict not in {"APPROVE", "REQUEST_CHANGES"}: From bfecfd8ba668f3bd66337ec645d18fb062dcf4cb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:58:04 +0000 Subject: [PATCH 55/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#judge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verdict と review_url/post_error の検証を _validate_review_verdict_and_posting として抽出した。振る舞いは変えて いない。 Item-Id: R2-004 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index fc0a116..f5aaf3a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -684,6 +684,31 @@ def _validate_review_payload(name: str, review: Any) -> Optional[str]: return None +def _validate_review_verdict_and_posting(name: str, review: dict[str, Any]) -> list[str]: + """verdict と review_url/post_error を検証する。 + + **投稿できていない判定は採らない。** 投稿が失敗しても結果ファイルの + 判定だけは残るため、そのまま採ると実装担当が読むべき指摘が + Pull Request に無いまま収束する。 + """ + problems: list[str] = [] + verdict = review.get("verdict") + if verdict not in {"APPROVE", "REQUEST_CHANGES"}: + problems.append( + f"{name} の判定 `{verdict}` は APPROVE / REQUEST_CHANGES のいずれかで" + "なければなりません(判定に COMMENT は使いません。" + "投稿の event とは別物です)" + ) + post_error = review.get("post_error") + if post_error: + problems.append(f"{name} がレビューを投稿できませんでした: {post_error}") + elif not review.get("review_url"): + problems.append( + f"{name} のレビュー URL がありません(投稿できていない可能性があります)" + ) + return problems + + def judge( reviews: dict[str, dict[str, Any]], reviewers: list[str], round_items: list[str] ) -> tuple[str, list[str]]: @@ -703,23 +728,7 @@ def judge( if payload_problem: problems.append(payload_problem) continue - verdict = review.get("verdict") - if verdict not in {"APPROVE", "REQUEST_CHANGES"}: - problems.append( - f"{name} の判定 `{verdict}` は APPROVE / REQUEST_CHANGES のいずれかで" - "なければなりません(判定に COMMENT は使いません。" - "投稿の event とは別物です)" - ) - # **投稿できていない判定は採らない。** 投稿が失敗しても結果ファイルの - # 判定だけは残るため、そのまま採ると実装担当が読むべき指摘が - # Pull Request に無いまま収束する。 - post_error = review.get("post_error") - if post_error: - problems.append(f"{name} がレビューを投稿できませんでした: {post_error}") - elif not review.get("review_url"): - problems.append( - f"{name} のレビュー URL がありません(投稿できていない可能性があります)" - ) + problems.extend(_validate_review_verdict_and_posting(name, review)) findings = review.get("findings") or [] if not isinstance(findings, list): problems.append(f"{name} の findings が配列ではありません") From 93d8d3d7fa7e2ae9b47eccf6d401d45cc3aa0a69 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 15:59:02 +0000 Subject: [PATCH 56/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20cross-refactoring=20refactor.py#judge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findings 配列と item_id の検証を _validate_review_findings として抽出 した。judge は問題を集約して approved/changes/invalid を判定するだけの 関数になった。振る舞いは変えていない。 Item-Id: R2-004 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 47 +++++++++++-------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f5aaf3a..cc7399f 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -709,6 +709,31 @@ def _validate_review_verdict_and_posting(name: str, review: dict[str, Any]) -> l return problems +def _validate_review_findings( + name: str, findings: Any, round_items: list[str] +) -> list[str]: + """findings 配列と各要素の item_id を検証する。""" + problems: list[str] = [] + if not isinstance(findings, list): + return [f"{name} の findings が配列ではありません"] + for i, finding in enumerate(findings): + if not isinstance(finding, dict): + problems.append( + f"{name} の指摘 {i + 1} が JSON オブジェクトではありません" + ) + continue + if "item_id" not in finding: + problems.append(f"{name} の指摘 {i + 1} に item_id がありません") + continue + item_id = finding["item_id"] + if item_id is not None and item_id not in round_items: + problems.append( + f"{name} の指摘 {i + 1} の item_id `{item_id}` は" + "このラウンドの改善項目ではありません" + ) + return problems + + def judge( reviews: dict[str, dict[str, Any]], reviewers: list[str], round_items: list[str] ) -> tuple[str, list[str]]: @@ -729,25 +754,9 @@ def judge( problems.append(payload_problem) continue problems.extend(_validate_review_verdict_and_posting(name, review)) - findings = review.get("findings") or [] - if not isinstance(findings, list): - problems.append(f"{name} の findings が配列ではありません") - continue - for i, finding in enumerate(findings): - if not isinstance(finding, dict): - problems.append( - f"{name} の指摘 {i + 1} が JSON オブジェクトではありません" - ) - continue - if "item_id" not in finding: - problems.append(f"{name} の指摘 {i + 1} に item_id がありません") - continue - item_id = finding["item_id"] - if item_id is not None and item_id not in round_items: - problems.append( - f"{name} の指摘 {i + 1} の item_id `{item_id}` は" - "このラウンドの改善項目ではありません" - ) + problems.extend( + _validate_review_findings(name, review.get("findings") or [], round_items) + ) if problems: return "invalid", problems if all((reviews[name] or {}).get("verdict") == "APPROVE" for name in reviewers): From 6a00ae8f92792975274a9b7833517572914b2637 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:01:46 +0000 Subject: [PATCH 57/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#=5Fdrop=5Fitems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drop_ids から未取り消しの pending を作る段を _pending_drop_item_ids として 抽出した。振る舞いは変えていない。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../skills/cross-refactoring/scripts/refactor.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index cc7399f..8401cc1 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2846,6 +2846,14 @@ def _commit_owner( return owner +def _pending_drop_item_ids(state: dict[str, Any], drop_ids: list[str]) -> list[str]: + """drop_ids から、まだ取り消されていない項目 ID だけを返す。""" + return [ + i for i in drop_ids + if not (_find_item(state, i, required=False) or {}).get("reverted") + ] + + def _drop_items( state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], dry_run: bool = False, @@ -2869,10 +2877,7 @@ def _drop_items( | `skip` | 取り消すものが無かった(取り消し済み) | """ work = state["worktrees"]["work"] - pending = [ - i for i in drop_ids - if not (_find_item(state, i, required=False) or {}).get("reverted") - ] + pending = _pending_drop_item_ids(state, drop_ids) if not pending: info("↩ 取り消し対象は取り消し済みです") return {"mode": "skip", "dropped": [], "reverted": 0, "replayed": 0} From 86016da8560edf89300956801a0410baeb464d32 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:02:44 +0000 Subject: [PATCH 58/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#=5Fdrop=5Fitems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit keep_ids と積み直し対象 SHA を求める段を _drop_replay_plan として抽出 した。振る舞いは変えていない。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 8401cc1..4c077ce 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2854,6 +2854,27 @@ def _pending_drop_item_ids(state: dict[str, Any], drop_ids: list[str]) -> list[s ] +def _drop_replay_plan( + state: dict[str, Any], entry: dict[str, Any], pending: list[str], + ordered: list[str], +) -> tuple[dict[str, str], list[str], list[str]]: + """残す項目 (`keep_ids`) と積み直す SHA (`replay`) を求める。 + + `ordered` は新しい順なので、積み直しは反転して古い順にする。 + **どの項目にも属さないコミット(過去の取り消しなど)は積み直さない。** + """ + work = state["worktrees"]["work"] + 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") + ] + replay = [s for s in reversed(ordered) if owner.get(s) in keep_ids] + return owner, keep_ids, replay + + def _drop_items( state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], dry_run: bool = False, @@ -2894,16 +2915,7 @@ def _drop_items( 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] + owner, keep_ids, replay = _drop_replay_plan(state, entry, pending, ordered) if dry_run: for sha in ordered: From 421e8788ba593a0e88a7e320efcb4d0164500c46 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:04:52 +0000 Subject: [PATCH 59/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#=5Fdrop=5Fitems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dry-run の出力と戻り値生成を _dry_run_drop_plan として抽出した。 振る舞いは変えていない。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 4c077ce..2f5b34f 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2875,6 +2875,18 @@ def _drop_replay_plan( return owner, keep_ids, replay +def _dry_run_drop_plan( + pending: list[str], ordered: list[str], replay: list[str] +) -> dict[str, Any]: + """dry-run 時の出力と戻り値を作る。実際の revert/cherry-pick は行わない。""" + 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)} + + def _drop_items( state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], dry_run: bool = False, @@ -2918,12 +2930,7 @@ def _drop_items( owner, keep_ids, replay = _drop_replay_plan(state, entry, pending, ordered) 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)} + return _dry_run_drop_plan(pending, ordered, replay) _revert_range(work, ordered, head) # 取り消しが済んだ地点。積み直しに失敗したらここへ戻せばよい。 From 2764087adc236d157e59e20b28aa58dea7e6e5e3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:05:48 +0000 Subject: [PATCH 60/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#=5Fdrop=5Fitems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit revert 後に replay し、失敗時に round モードへ退避する段を _execute_drop_replay として抽出した。振る舞いは変えていない。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2f5b34f..ca07d24 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2887,6 +2887,27 @@ def _dry_run_drop_plan( "reverted": len(ordered), "replayed": len(replay)} +def _execute_drop_replay( + work: str, ordered: list[str], head: Optional[str], replay: list[str], +) -> tuple[dict[str, str], str]: + """範囲を取り消して残す項目を積み直す。積み直しに失敗したら round モードへ退避する。 + + 戻り値は `(mapping, mode)`。`mapping` は積み直し後の SHA 対応 + (`round` モードでは空)。 + """ + _revert_range(work, ordered, head) + # 取り消しが済んだ地点。積み直しに失敗したらここへ戻せばよい。 + reverted_head = _git_out(work, ["rev-parse", "HEAD"]) + mapping = _replay_commits(work, replay) + if mapping is None: + info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") + # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが + # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 + _reset_hard(work, reverted_head) + return {}, "round" + return mapping, "item" + + def _drop_items( state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], dry_run: bool = False, @@ -2932,17 +2953,7 @@ def _drop_items( if dry_run: return _dry_run_drop_plan(pending, ordered, 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("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") - # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが - # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 - _reset_hard(work, reverted_head) - mapping, mode = {}, "round" + mapping, mode = _execute_drop_replay(work, ordered, head, replay) dropped = list(entry["items"]) if mode == "round" else pending for item_id in entry["items"]: From 4856c7e8e658c43faf1bce72dc0646c86e808b92 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:06:54 +0000 Subject: [PATCH 61/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20refactor.py#=5Fdrop=5Fitems?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit item の reverted/commits と entry.drops の更新を _record_drop_result として 抽出した。_drop_items は取り消し対象の抽出・積み直し計画・dry-run 出力・ revert/cherry-pick 実行・状態記録の 5 段が、それぞれ名前の付いた関数 呼び出しとして直列に読める形になった。振る舞いは変えていない。 Item-Id: R2-005 Round: 2 Impl-Runtime: kiro Impl-Model: claude-sonnet-5 --- .../cross-refactoring/scripts/refactor.py | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ca07d24..5f23eb2 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2908,6 +2908,42 @@ def _execute_drop_replay( return mapping, "item" +def _record_drop_result( + state: dict[str, Any], + entry: dict[str, Any], + pending: list[str], + keep_ids: list[str], + ordered: list[str], + replay: list[str], + owner: dict[str, str], + mapping: dict[str, str], + mode: str, +) -> dict[str, Any]: + """item の reverted/commits と entry.drops を更新し、結果を返す。""" + 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 _drop_items( state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], dry_run: bool = False, @@ -2955,28 +2991,9 @@ def _drop_items( mapping, mode = _execute_drop_replay(work, ordered, head, replay) - 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 _record_drop_result( + state, entry, pending, keep_ids, ordered, replay, owner, mapping, mode, ) - return {"mode": mode, "dropped": dropped, - "reverted": len(ordered), "replayed": len(mapping)} def _order_newest_first(work: str, shas: list[str]) -> list[str]: From 69776689e6f0703a8b122812cb808d2ff24b1c8f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:16:01 +0000 Subject: [PATCH 62/90] =?UTF-8?q?Chore:=20=E7=94=9F=E6=88=90=E7=89=A9?= =?UTF-8?q?=E3=82=92=E5=90=8C=E6=9C=9F=E3=81=99=E3=82=8B=EF=BC=88cross-ref?= =?UTF-8?q?actoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当は対象範囲だけを変更するため、生成物が同期されない。 同期を検査する pre-push を持つリポジトリでも push できるよう、 公開の直前に進行側がまとめて生成する。 --- .../cross-refactoring/scripts/refactor.py | 611 +++++++++++------- .../tests/test_abandon_items.py | 48 ++ .../cross-refactoring/scripts/refactor.py | 611 +++++++++++------- .../tests/test_abandon_items.py | 48 ++ .../cross-refactoring/scripts/refactor.py | 611 +++++++++++------- .../tests/test_abandon_items.py | 48 ++ 6 files changed, 1326 insertions(+), 651 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 8bce5f3..5f23eb2 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -671,6 +671,69 @@ def _unposted_reviewers( return names, problems +def _validate_review_payload(name: str, review: Any) -> Optional[str]: + """review の存在と dict 型を確認する。 + + 出力の形が崩れていても落ちないようにする。相手は LLM なので、 + 期待した型で返ってこないことがある。崩れていたら差し戻す。 + """ + if not review: + return f"{name} のレビュー結果がありません" + if not isinstance(review, dict): + return f"{name} のレビュー結果が JSON オブジェクトではありません" + return None + + +def _validate_review_verdict_and_posting(name: str, review: dict[str, Any]) -> list[str]: + """verdict と review_url/post_error を検証する。 + + **投稿できていない判定は採らない。** 投稿が失敗しても結果ファイルの + 判定だけは残るため、そのまま採ると実装担当が読むべき指摘が + Pull Request に無いまま収束する。 + """ + problems: list[str] = [] + verdict = review.get("verdict") + if verdict not in {"APPROVE", "REQUEST_CHANGES"}: + problems.append( + f"{name} の判定 `{verdict}` は APPROVE / REQUEST_CHANGES のいずれかで" + "なければなりません(判定に COMMENT は使いません。" + "投稿の event とは別物です)" + ) + post_error = review.get("post_error") + if post_error: + problems.append(f"{name} がレビューを投稿できませんでした: {post_error}") + elif not review.get("review_url"): + problems.append( + f"{name} のレビュー URL がありません(投稿できていない可能性があります)" + ) + return problems + + +def _validate_review_findings( + name: str, findings: Any, round_items: list[str] +) -> list[str]: + """findings 配列と各要素の item_id を検証する。""" + problems: list[str] = [] + if not isinstance(findings, list): + return [f"{name} の findings が配列ではありません"] + for i, finding in enumerate(findings): + if not isinstance(finding, dict): + problems.append( + f"{name} の指摘 {i + 1} が JSON オブジェクトではありません" + ) + continue + if "item_id" not in finding: + problems.append(f"{name} の指摘 {i + 1} に item_id がありません") + continue + item_id = finding["item_id"] + if item_id is not None and item_id not in round_items: + problems.append( + f"{name} の指摘 {i + 1} の item_id `{item_id}` は" + "このラウンドの改善項目ではありません" + ) + return problems + + def judge( reviews: dict[str, dict[str, Any]], reviewers: list[str], round_items: list[str] ) -> tuple[str, list[str]]: @@ -683,53 +746,17 @@ def judge( そのラウンドに無い ID や欠落は判定に使えない。ラウンド全体に対する指摘は `null` を明示させ、取り消し時はラウンド全件の対象とする。 """ - # 出力の形が崩れていても落ちないようにする。相手は LLM なので、 - # 期待した型で返ってこないことがある。崩れていたら差し戻す。 problems: list[str] = [] for name in reviewers: review = reviews.get(name) - if not review: - problems.append(f"{name} のレビュー結果がありません") + payload_problem = _validate_review_payload(name, review) + if payload_problem: + problems.append(payload_problem) continue - if not isinstance(review, dict): - problems.append(f"{name} のレビュー結果が JSON オブジェクトではありません") - continue - verdict = review.get("verdict") - if verdict not in {"APPROVE", "REQUEST_CHANGES"}: - problems.append( - f"{name} の判定 `{verdict}` は APPROVE / REQUEST_CHANGES のいずれかで" - "なければなりません(判定に COMMENT は使いません。" - "投稿の event とは別物です)" - ) - # **投稿できていない判定は採らない。** 投稿が失敗しても結果ファイルの - # 判定だけは残るため、そのまま採ると実装担当が読むべき指摘が - # Pull Request に無いまま収束する。 - post_error = review.get("post_error") - if post_error: - problems.append(f"{name} がレビューを投稿できませんでした: {post_error}") - elif not review.get("review_url"): - problems.append( - f"{name} のレビュー URL がありません(投稿できていない可能性があります)" - ) - findings = review.get("findings") or [] - if not isinstance(findings, list): - problems.append(f"{name} の findings が配列ではありません") - continue - for i, finding in enumerate(findings): - if not isinstance(finding, dict): - problems.append( - f"{name} の指摘 {i + 1} が JSON オブジェクトではありません" - ) - continue - if "item_id" not in finding: - problems.append(f"{name} の指摘 {i + 1} に item_id がありません") - continue - item_id = finding["item_id"] - if item_id is not None and item_id not in round_items: - problems.append( - f"{name} の指摘 {i + 1} の item_id `{item_id}` は" - "このラウンドの改善項目ではありません" - ) + problems.extend(_validate_review_verdict_and_posting(name, review)) + problems.extend( + _validate_review_findings(name, review.get("findings") or [], round_items) + ) if problems: return "invalid", problems if all((reviews[name] or {}).get("verdict") == "APPROVE" for name in reviewers): @@ -1154,30 +1181,14 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 +def _load_runtime_proposals( + state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """各ランタイムの提案結果ファイルを読み込み、JSON を解析する。 - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + 1 者が結果ファイルを欠かした・壊れた JSON を返した場合も、その者の提案を + 無かったものとして扱い、全体の統合は続ける。 """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1206,17 +1217,17 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: proposals[runtime] = [i for i in items if isinstance(i, dict)] \ if isinstance(items, list) else [] entry["proposed"][runtime] = len(proposals[runtime]) + return proposals - excluded = { - (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] - } - adopted, deferred = merge_proposals( - proposals, - threshold=state["severity_threshold"], - max_items=state["max_items_per_round"], - excluded_keys=excluded, - ) +def _update_state_from_merged_proposals( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + adopted: list[dict[str, Any]], + deferred: list[dict[str, Any]], +) -> None: + """統合済みの提案から状態オブジェクトを更新し、次回ラウンドの起点を準備する。""" # 収束判定に使う「前ラウンドとの重複率」。見送りも含めた提案全体で測る。 current_keys = [(i["path"], i["symbol"], i["smell"]) for i in adopted + deferred] entry["proposal_keys"] = [list(k) for k in current_keys] @@ -1250,6 +1261,45 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: state["final"] = "no_more_proposals" state["ended_at"] = statefile.now() statefile.save(path, state) + + +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _load_runtime_proposals(state, entry) + + excluded = { + (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] + } + adopted, deferred = merge_proposals( + proposals, + threshold=state["severity_threshold"], + max_items=state["max_items_per_round"], + excluded_keys=excluded, + ) + + _update_state_from_merged_proposals(path, state, entry, adopted, deferred) info( f"提案 {sum(entry['proposed'].values())} 件 → 統合 {entry['merged']} 件 → " f"採用 {entry['adopted']} 件 / 見送り {entry['deferred']} 件" @@ -1426,27 +1476,18 @@ def _collect_apply_reports( return reported, unknown_ids -def _validate_apply_commit_ownership( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - args: argparse.Namespace, - reported: dict[str, dict[str, Any]], - unknown_ids: list[str], - work: pathlib.Path, - ordered_range: list[str], - in_range: set[str], - head_sha: str, -) -> None: - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 +def _detect_commit_owners( + work: pathlib.Path, reported: dict[str, dict[str, Any]] +) -> tuple[dict[str, str], list[str]]: + """申告コミットを完全 SHA へ正規化し、所有項目と重複申告を特定する。 + + **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + 実際の差分が食い違い、どちらが正しいか決められなくなる。 + + 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + """ owner_of: dict[str, str] = {} duplicated: list[str] = [] for item_id, r in reported.items(): @@ -1457,11 +1498,13 @@ def _validate_apply_commit_ownership( if full in owner_of and owner_of[full] != item_id: duplicated.append(full) owner_of.setdefault(full, item_id) + return owner_of, duplicated - unassigned = sorted(in_range - set(owner_of)) - if not (unassigned or unknown_ids or duplicated): - return +def _build_ownership_error_reason( + unassigned: list[str], unknown_ids: list[str], duplicated: list[str] +) -> str: + """所有権検査の失敗理由を組み立てる。""" causes = [] if unassigned: causes.append( @@ -1478,16 +1521,26 @@ def _validate_apply_commit_ownership( f"複数の項目が同じコミットを申告しています" f"({', '.join(s[:7] for s in duplicated[:5])})" ) - reason = ( + return ( "、".join(causes) + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" "残さないため、ラウンドごと取り消します" ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason + + +def _revert_unverified_apply_round( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + work: pathlib.Path, + ordered_range: list[str], + unassigned: list[str], + unknown_ids: list[str], + duplicated: list[str], + head_sha: str, +) -> None: + """検証を通らない適用ラウンドの範囲を取り消し、状態と公開を反映する。""" # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 @@ -1524,6 +1577,39 @@ def _validate_apply_commit_ownership( _push_head(state) entry["pending_push"] = False statefile.save(path, state) + + +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + owner_of, duplicated = _detect_commit_owners(work, reported) + + unassigned = sorted(in_range - set(owner_of)) + if not (unassigned or unknown_ids or duplicated): + return + + reason = _build_ownership_error_reason(unassigned, unknown_ids, duplicated) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + _revert_unverified_apply_round( + path, state, entry, args, work, ordered_range, + unassigned, unknown_ids, duplicated, head_sha, + ) sys.exit(2) @@ -1979,49 +2065,50 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def cmd_merge_fix(args: argparse.Namespace) -> None: - """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" - path, state = _load(args.id) - entry = _round(state, args.round) - _discard_impl_leftovers(state, state["worktrees"]["work"]) - _flush_pending_push(path, state, entry) - impl = entry["impl"] - result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) - payload = _read_result(result, impl) +def _fix_merge_key(entry: dict[str, Any], result: pathlib.Path) -> str: + """修正結果の取り込み済み判定に使う鍵を作る。 - # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 - # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 - # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 - # 鍵は**試行番号と結果ファイルの内容**から作る。 - # - # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず - # 同じ申告を再処理してしまう。 - # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ - # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま - # 同じ修正を起動し続ける。 - # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に - # なりうる。 - # - # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が - # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 - work = state["worktrees"]["work"] - head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" + **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 + 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 + 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 + 鍵は**試行番号と結果ファイルの内容**から作る。 + + - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず + 同じ申告を再処理してしまう。 + - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ + 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま + 同じ修正を起動し続ける。 + - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に + なりうる。 + + 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が + **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 + """ attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def _already_merged_fix_result( + entry: dict[str, Any], merge_key: str +) -> bool: + """この修正結果を取り込み済みなら真を返し、鍵の一覧を更新する。""" merged_keys = entry.setdefault("fix_merged_keys", []) if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" ) - return + return True + return False - # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + +def _resolved_fix_thread_ids(payload: dict[str, Any], repo: str, pr: int) -> set[str]: + """自己申告と GitHub 側の解決状態を突き合わせ、両方が解決と言う ID だけ返す。 + + 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + """ raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** @@ -2032,15 +2119,75 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if raw_claimed is not None and not isinstance(raw_claimed, list): info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + actual = resolved_threads_on_github(repo, pr) if actual is None: info("⚠ レビュースレッドの解決状態を取得できませんでした。" "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + +def _unassigned_fix_commits( + work: str, reported_shas: list[str], ordered_range: list[str] +) -> list[str]: + """範囲内のコミットのうち、どの申告にも含まれていないものを返す。 + + 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + """ + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + return sorted(set(ordered_range) - reported_full) + + +def _verify_fix_commits( + facts: list[dict[str, Any]], scope: list[str] +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミットを検証し、問題点の一覧と受理した (item_id, sha) を返す。 + + **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** + 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける + (見送りの対象にもならない)。どのコミットが安全かは決められないので、 + 適用フェーズの未割当コミットと同じ扱いにする。 + """ + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return problems, accepted + + +def cmd_merge_fix(args: argparse.Namespace) -> None: + """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" + path, state = _load(args.id) + entry = _round(state, args.round) + _discard_impl_leftovers(state, state["worktrees"]["work"]) + _flush_pending_push(path, state, entry) + impl = entry["impl"] + result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) + payload = _read_result(result, impl) + + work = state["worktrees"]["work"] + head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" + merge_key = _fix_merge_key(entry, result) + if _already_merged_fix_result(entry, merge_key): + return + merged_keys = entry["fix_merged_keys"] + + resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 @@ -2062,16 +2209,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: code=2, ) reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) + unassigned = _unassigned_fix_commits(work, reported_shas, ordered_range) facts = collect_commit_facts( work, reported_shas, set(ordered_range), @@ -2079,20 +2217,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** - # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける - # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 - # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + problems, accepted = _verify_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( @@ -2721,49 +2846,24 @@ def _commit_owner( 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 = [ +def _pending_drop_item_ids(state: dict[str, Any], drop_ids: list[str]) -> list[str]: + """drop_ids から、まだ取り消されていない項目 ID だけを返す。""" + return [ 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} +def _drop_replay_plan( + state: dict[str, Any], entry: dict[str, Any], pending: list[str], + ordered: list[str], +) -> tuple[dict[str, str], list[str], list[str]]: + """残す項目 (`keep_ids`) と積み直す SHA (`replay`) を求める。 + + `ordered` は新しい順なので、積み直しは反転して古い順にする。 + **どの項目にも属さないコミット(過去の取り消しなど)は積み直さない。** + """ + work = state["worktrees"]["work"] owner = _commit_owner(work, state, entry) drop = set(pending) keep_ids = [ @@ -2771,30 +2871,55 @@ def _drop_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] + return owner, keep_ids, replay + + +def _dry_run_drop_plan( + pending: list[str], ordered: list[str], replay: list[str] +) -> dict[str, Any]: + """dry-run 時の出力と戻り値を作る。実際の revert/cherry-pick は行わない。""" + 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)} - 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)} +def _execute_drop_replay( + work: str, ordered: list[str], head: Optional[str], replay: list[str], +) -> tuple[dict[str, str], str]: + """範囲を取り消して残す項目を積み直す。積み直しに失敗したら round モードへ退避する。 + + 戻り値は `(mapping, mode)`。`mapping` は積み直し後の SHA 対応 + (`round` モードでは空)。 + """ _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("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 _reset_hard(work, reverted_head) - mapping, mode = {}, "round" + return {}, "round" + return mapping, "item" + +def _record_drop_result( + state: dict[str, Any], + entry: dict[str, Any], + pending: list[str], + keep_ids: list[str], + ordered: list[str], + replay: list[str], + owner: dict[str, str], + mapping: dict[str, str], + mode: str, +) -> dict[str, Any]: + """item の reverted/commits と entry.drops を更新し、結果を返す。""" dropped = list(entry["items"]) if mode == "round" else pending for item_id in entry["items"]: item = _find_item(state, item_id, required=False) @@ -2819,6 +2944,58 @@ def _drop_items( "reverted": len(ordered), "replayed": len(mapping)} +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 = _pending_drop_item_ids(state, drop_ids) + 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, keep_ids, replay = _drop_replay_plan(state, entry, pending, ordered) + + if dry_run: + return _dry_run_drop_plan(pending, ordered, replay) + + mapping, mode = _execute_drop_replay(work, ordered, head, replay) + + return _record_drop_result( + state, entry, pending, keep_ids, ordered, replay, owner, mapping, mode, + ) + + 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 5101513..2ed8f26 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 @@ -387,6 +387,54 @@ def fake_run(cmd, **kw): assert any("cursor=C1" in "".join(c) for c in calls) +def test_merge_fix_skips_already_merged_result( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """同じ試行番号・同じ結果ファイルの内容は二重に取り込まない。 + + 叩き直しても鍵(試行番号 + 結果ファイルのハッシュ)が変わらなければ、 + fix_rounds を進めず即座に戻る。 + """ + state_path = _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + state = read_state(state_path) + result_path = state_path.parent / "codex-fix-r1-result.json" + import hashlib as _hashlib + merge_key = "1:" + _hashlib.sha256(result_path.read_bytes()).hexdigest() + state["rounds"][0]["fix_merged_keys"] = [merge_key] + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + monkeypatch.setattr(refactor, "resolved_threads_on_github", + lambda repo, pr: {"PRRT_a"}) + refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + after = read_state(state_path) + # fix_rounds は進まず、解決も反映されない(早期に戻ったため) + assert after["rounds"][0]["fix_rounds"] == 0 + assert after["rounds"][0]["reviews"][0]["findings"][0]["resolved"] is False + + +def test_merge_fix_rejects_commits_missing_from_the_claim( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """範囲に含まれるが誰の申告にも無いコミットがあれば、範囲ごと取り消す。""" + state_path = _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + monkeypatch.setattr(refactor, "resolved_threads_on_github", + lambda repo, pr: {"PRRT_a"}) + # 範囲には fix111 の他に申告されていない stray999 が含まれる + monkeypatch.setattr( + refactor, "commits_in_range", + lambda work, base, head: ["fix111", "stray999"], + ) + calls = _no_git(refactor, monkeypatch) + refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + state = read_state(state_path) + assert state["rounds"][0]["reviews"][0]["findings"][0]["resolved"] is False + assert "fix111" not in state["items"][0]["commits"] + reverted = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + assert "stray999" in reverted and "fix111" in reverted + assert any(c[:2] == ["git", "push"] for c in calls) + + @pytest.mark.parametrize("broken_ids", ["文字列", 123, True, {"a": 1}]) def test_broken_fix_result_does_not_crash( refactor, tmp_path, env_tmp_dir, monkeypatch, broken_ids diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 8bce5f3..5f23eb2 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -671,6 +671,69 @@ def _unposted_reviewers( return names, problems +def _validate_review_payload(name: str, review: Any) -> Optional[str]: + """review の存在と dict 型を確認する。 + + 出力の形が崩れていても落ちないようにする。相手は LLM なので、 + 期待した型で返ってこないことがある。崩れていたら差し戻す。 + """ + if not review: + return f"{name} のレビュー結果がありません" + if not isinstance(review, dict): + return f"{name} のレビュー結果が JSON オブジェクトではありません" + return None + + +def _validate_review_verdict_and_posting(name: str, review: dict[str, Any]) -> list[str]: + """verdict と review_url/post_error を検証する。 + + **投稿できていない判定は採らない。** 投稿が失敗しても結果ファイルの + 判定だけは残るため、そのまま採ると実装担当が読むべき指摘が + Pull Request に無いまま収束する。 + """ + problems: list[str] = [] + verdict = review.get("verdict") + if verdict not in {"APPROVE", "REQUEST_CHANGES"}: + problems.append( + f"{name} の判定 `{verdict}` は APPROVE / REQUEST_CHANGES のいずれかで" + "なければなりません(判定に COMMENT は使いません。" + "投稿の event とは別物です)" + ) + post_error = review.get("post_error") + if post_error: + problems.append(f"{name} がレビューを投稿できませんでした: {post_error}") + elif not review.get("review_url"): + problems.append( + f"{name} のレビュー URL がありません(投稿できていない可能性があります)" + ) + return problems + + +def _validate_review_findings( + name: str, findings: Any, round_items: list[str] +) -> list[str]: + """findings 配列と各要素の item_id を検証する。""" + problems: list[str] = [] + if not isinstance(findings, list): + return [f"{name} の findings が配列ではありません"] + for i, finding in enumerate(findings): + if not isinstance(finding, dict): + problems.append( + f"{name} の指摘 {i + 1} が JSON オブジェクトではありません" + ) + continue + if "item_id" not in finding: + problems.append(f"{name} の指摘 {i + 1} に item_id がありません") + continue + item_id = finding["item_id"] + if item_id is not None and item_id not in round_items: + problems.append( + f"{name} の指摘 {i + 1} の item_id `{item_id}` は" + "このラウンドの改善項目ではありません" + ) + return problems + + def judge( reviews: dict[str, dict[str, Any]], reviewers: list[str], round_items: list[str] ) -> tuple[str, list[str]]: @@ -683,53 +746,17 @@ def judge( そのラウンドに無い ID や欠落は判定に使えない。ラウンド全体に対する指摘は `null` を明示させ、取り消し時はラウンド全件の対象とする。 """ - # 出力の形が崩れていても落ちないようにする。相手は LLM なので、 - # 期待した型で返ってこないことがある。崩れていたら差し戻す。 problems: list[str] = [] for name in reviewers: review = reviews.get(name) - if not review: - problems.append(f"{name} のレビュー結果がありません") + payload_problem = _validate_review_payload(name, review) + if payload_problem: + problems.append(payload_problem) continue - if not isinstance(review, dict): - problems.append(f"{name} のレビュー結果が JSON オブジェクトではありません") - continue - verdict = review.get("verdict") - if verdict not in {"APPROVE", "REQUEST_CHANGES"}: - problems.append( - f"{name} の判定 `{verdict}` は APPROVE / REQUEST_CHANGES のいずれかで" - "なければなりません(判定に COMMENT は使いません。" - "投稿の event とは別物です)" - ) - # **投稿できていない判定は採らない。** 投稿が失敗しても結果ファイルの - # 判定だけは残るため、そのまま採ると実装担当が読むべき指摘が - # Pull Request に無いまま収束する。 - post_error = review.get("post_error") - if post_error: - problems.append(f"{name} がレビューを投稿できませんでした: {post_error}") - elif not review.get("review_url"): - problems.append( - f"{name} のレビュー URL がありません(投稿できていない可能性があります)" - ) - findings = review.get("findings") or [] - if not isinstance(findings, list): - problems.append(f"{name} の findings が配列ではありません") - continue - for i, finding in enumerate(findings): - if not isinstance(finding, dict): - problems.append( - f"{name} の指摘 {i + 1} が JSON オブジェクトではありません" - ) - continue - if "item_id" not in finding: - problems.append(f"{name} の指摘 {i + 1} に item_id がありません") - continue - item_id = finding["item_id"] - if item_id is not None and item_id not in round_items: - problems.append( - f"{name} の指摘 {i + 1} の item_id `{item_id}` は" - "このラウンドの改善項目ではありません" - ) + problems.extend(_validate_review_verdict_and_posting(name, review)) + problems.extend( + _validate_review_findings(name, review.get("findings") or [], round_items) + ) if problems: return "invalid", problems if all((reviews[name] or {}).get("verdict") == "APPROVE" for name in reviewers): @@ -1154,30 +1181,14 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 +def _load_runtime_proposals( + state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """各ランタイムの提案結果ファイルを読み込み、JSON を解析する。 - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + 1 者が結果ファイルを欠かした・壊れた JSON を返した場合も、その者の提案を + 無かったものとして扱い、全体の統合は続ける。 """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1206,17 +1217,17 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: proposals[runtime] = [i for i in items if isinstance(i, dict)] \ if isinstance(items, list) else [] entry["proposed"][runtime] = len(proposals[runtime]) + return proposals - excluded = { - (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] - } - adopted, deferred = merge_proposals( - proposals, - threshold=state["severity_threshold"], - max_items=state["max_items_per_round"], - excluded_keys=excluded, - ) +def _update_state_from_merged_proposals( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + adopted: list[dict[str, Any]], + deferred: list[dict[str, Any]], +) -> None: + """統合済みの提案から状態オブジェクトを更新し、次回ラウンドの起点を準備する。""" # 収束判定に使う「前ラウンドとの重複率」。見送りも含めた提案全体で測る。 current_keys = [(i["path"], i["symbol"], i["smell"]) for i in adopted + deferred] entry["proposal_keys"] = [list(k) for k in current_keys] @@ -1250,6 +1261,45 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: state["final"] = "no_more_proposals" state["ended_at"] = statefile.now() statefile.save(path, state) + + +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _load_runtime_proposals(state, entry) + + excluded = { + (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] + } + adopted, deferred = merge_proposals( + proposals, + threshold=state["severity_threshold"], + max_items=state["max_items_per_round"], + excluded_keys=excluded, + ) + + _update_state_from_merged_proposals(path, state, entry, adopted, deferred) info( f"提案 {sum(entry['proposed'].values())} 件 → 統合 {entry['merged']} 件 → " f"採用 {entry['adopted']} 件 / 見送り {entry['deferred']} 件" @@ -1426,27 +1476,18 @@ def _collect_apply_reports( return reported, unknown_ids -def _validate_apply_commit_ownership( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - args: argparse.Namespace, - reported: dict[str, dict[str, Any]], - unknown_ids: list[str], - work: pathlib.Path, - ordered_range: list[str], - in_range: set[str], - head_sha: str, -) -> None: - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 +def _detect_commit_owners( + work: pathlib.Path, reported: dict[str, dict[str, Any]] +) -> tuple[dict[str, str], list[str]]: + """申告コミットを完全 SHA へ正規化し、所有項目と重複申告を特定する。 + + **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + 実際の差分が食い違い、どちらが正しいか決められなくなる。 + + 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + """ owner_of: dict[str, str] = {} duplicated: list[str] = [] for item_id, r in reported.items(): @@ -1457,11 +1498,13 @@ def _validate_apply_commit_ownership( if full in owner_of and owner_of[full] != item_id: duplicated.append(full) owner_of.setdefault(full, item_id) + return owner_of, duplicated - unassigned = sorted(in_range - set(owner_of)) - if not (unassigned or unknown_ids or duplicated): - return +def _build_ownership_error_reason( + unassigned: list[str], unknown_ids: list[str], duplicated: list[str] +) -> str: + """所有権検査の失敗理由を組み立てる。""" causes = [] if unassigned: causes.append( @@ -1478,16 +1521,26 @@ def _validate_apply_commit_ownership( f"複数の項目が同じコミットを申告しています" f"({', '.join(s[:7] for s in duplicated[:5])})" ) - reason = ( + return ( "、".join(causes) + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" "残さないため、ラウンドごと取り消します" ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason + + +def _revert_unverified_apply_round( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + work: pathlib.Path, + ordered_range: list[str], + unassigned: list[str], + unknown_ids: list[str], + duplicated: list[str], + head_sha: str, +) -> None: + """検証を通らない適用ラウンドの範囲を取り消し、状態と公開を反映する。""" # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 @@ -1524,6 +1577,39 @@ def _validate_apply_commit_ownership( _push_head(state) entry["pending_push"] = False statefile.save(path, state) + + +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + owner_of, duplicated = _detect_commit_owners(work, reported) + + unassigned = sorted(in_range - set(owner_of)) + if not (unassigned or unknown_ids or duplicated): + return + + reason = _build_ownership_error_reason(unassigned, unknown_ids, duplicated) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + _revert_unverified_apply_round( + path, state, entry, args, work, ordered_range, + unassigned, unknown_ids, duplicated, head_sha, + ) sys.exit(2) @@ -1979,49 +2065,50 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def cmd_merge_fix(args: argparse.Namespace) -> None: - """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" - path, state = _load(args.id) - entry = _round(state, args.round) - _discard_impl_leftovers(state, state["worktrees"]["work"]) - _flush_pending_push(path, state, entry) - impl = entry["impl"] - result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) - payload = _read_result(result, impl) +def _fix_merge_key(entry: dict[str, Any], result: pathlib.Path) -> str: + """修正結果の取り込み済み判定に使う鍵を作る。 - # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 - # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 - # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 - # 鍵は**試行番号と結果ファイルの内容**から作る。 - # - # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず - # 同じ申告を再処理してしまう。 - # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ - # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま - # 同じ修正を起動し続ける。 - # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に - # なりうる。 - # - # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が - # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 - work = state["worktrees"]["work"] - head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" + **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 + 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 + 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 + 鍵は**試行番号と結果ファイルの内容**から作る。 + + - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず + 同じ申告を再処理してしまう。 + - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ + 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま + 同じ修正を起動し続ける。 + - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に + なりうる。 + + 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が + **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 + """ attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def _already_merged_fix_result( + entry: dict[str, Any], merge_key: str +) -> bool: + """この修正結果を取り込み済みなら真を返し、鍵の一覧を更新する。""" merged_keys = entry.setdefault("fix_merged_keys", []) if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" ) - return + return True + return False - # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + +def _resolved_fix_thread_ids(payload: dict[str, Any], repo: str, pr: int) -> set[str]: + """自己申告と GitHub 側の解決状態を突き合わせ、両方が解決と言う ID だけ返す。 + + 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + """ raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** @@ -2032,15 +2119,75 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if raw_claimed is not None and not isinstance(raw_claimed, list): info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + actual = resolved_threads_on_github(repo, pr) if actual is None: info("⚠ レビュースレッドの解決状態を取得できませんでした。" "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + +def _unassigned_fix_commits( + work: str, reported_shas: list[str], ordered_range: list[str] +) -> list[str]: + """範囲内のコミットのうち、どの申告にも含まれていないものを返す。 + + 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + """ + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + return sorted(set(ordered_range) - reported_full) + + +def _verify_fix_commits( + facts: list[dict[str, Any]], scope: list[str] +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミットを検証し、問題点の一覧と受理した (item_id, sha) を返す。 + + **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** + 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける + (見送りの対象にもならない)。どのコミットが安全かは決められないので、 + 適用フェーズの未割当コミットと同じ扱いにする。 + """ + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return problems, accepted + + +def cmd_merge_fix(args: argparse.Namespace) -> None: + """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" + path, state = _load(args.id) + entry = _round(state, args.round) + _discard_impl_leftovers(state, state["worktrees"]["work"]) + _flush_pending_push(path, state, entry) + impl = entry["impl"] + result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) + payload = _read_result(result, impl) + + work = state["worktrees"]["work"] + head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" + merge_key = _fix_merge_key(entry, result) + if _already_merged_fix_result(entry, merge_key): + return + merged_keys = entry["fix_merged_keys"] + + resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 @@ -2062,16 +2209,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: code=2, ) reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) + unassigned = _unassigned_fix_commits(work, reported_shas, ordered_range) facts = collect_commit_facts( work, reported_shas, set(ordered_range), @@ -2079,20 +2217,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** - # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける - # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 - # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + problems, accepted = _verify_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( @@ -2721,49 +2846,24 @@ def _commit_owner( 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 = [ +def _pending_drop_item_ids(state: dict[str, Any], drop_ids: list[str]) -> list[str]: + """drop_ids から、まだ取り消されていない項目 ID だけを返す。""" + return [ 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} +def _drop_replay_plan( + state: dict[str, Any], entry: dict[str, Any], pending: list[str], + ordered: list[str], +) -> tuple[dict[str, str], list[str], list[str]]: + """残す項目 (`keep_ids`) と積み直す SHA (`replay`) を求める。 + + `ordered` は新しい順なので、積み直しは反転して古い順にする。 + **どの項目にも属さないコミット(過去の取り消しなど)は積み直さない。** + """ + work = state["worktrees"]["work"] owner = _commit_owner(work, state, entry) drop = set(pending) keep_ids = [ @@ -2771,30 +2871,55 @@ def _drop_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] + return owner, keep_ids, replay + + +def _dry_run_drop_plan( + pending: list[str], ordered: list[str], replay: list[str] +) -> dict[str, Any]: + """dry-run 時の出力と戻り値を作る。実際の revert/cherry-pick は行わない。""" + 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)} - 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)} +def _execute_drop_replay( + work: str, ordered: list[str], head: Optional[str], replay: list[str], +) -> tuple[dict[str, str], str]: + """範囲を取り消して残す項目を積み直す。積み直しに失敗したら round モードへ退避する。 + + 戻り値は `(mapping, mode)`。`mapping` は積み直し後の SHA 対応 + (`round` モードでは空)。 + """ _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("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 _reset_hard(work, reverted_head) - mapping, mode = {}, "round" + return {}, "round" + return mapping, "item" + +def _record_drop_result( + state: dict[str, Any], + entry: dict[str, Any], + pending: list[str], + keep_ids: list[str], + ordered: list[str], + replay: list[str], + owner: dict[str, str], + mapping: dict[str, str], + mode: str, +) -> dict[str, Any]: + """item の reverted/commits と entry.drops を更新し、結果を返す。""" dropped = list(entry["items"]) if mode == "round" else pending for item_id in entry["items"]: item = _find_item(state, item_id, required=False) @@ -2819,6 +2944,58 @@ def _drop_items( "reverted": len(ordered), "replayed": len(mapping)} +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 = _pending_drop_item_ids(state, drop_ids) + 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, keep_ids, replay = _drop_replay_plan(state, entry, pending, ordered) + + if dry_run: + return _dry_run_drop_plan(pending, ordered, replay) + + mapping, mode = _execute_drop_replay(work, ordered, head, replay) + + return _record_drop_result( + state, entry, pending, keep_ids, ordered, replay, owner, mapping, mode, + ) + + 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 5101513..2ed8f26 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 @@ -387,6 +387,54 @@ def fake_run(cmd, **kw): assert any("cursor=C1" in "".join(c) for c in calls) +def test_merge_fix_skips_already_merged_result( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """同じ試行番号・同じ結果ファイルの内容は二重に取り込まない。 + + 叩き直しても鍵(試行番号 + 結果ファイルのハッシュ)が変わらなければ、 + fix_rounds を進めず即座に戻る。 + """ + state_path = _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + state = read_state(state_path) + result_path = state_path.parent / "codex-fix-r1-result.json" + import hashlib as _hashlib + merge_key = "1:" + _hashlib.sha256(result_path.read_bytes()).hexdigest() + state["rounds"][0]["fix_merged_keys"] = [merge_key] + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + monkeypatch.setattr(refactor, "resolved_threads_on_github", + lambda repo, pr: {"PRRT_a"}) + refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + after = read_state(state_path) + # fix_rounds は進まず、解決も反映されない(早期に戻ったため) + assert after["rounds"][0]["fix_rounds"] == 0 + assert after["rounds"][0]["reviews"][0]["findings"][0]["resolved"] is False + + +def test_merge_fix_rejects_commits_missing_from_the_claim( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """範囲に含まれるが誰の申告にも無いコミットがあれば、範囲ごと取り消す。""" + state_path = _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + monkeypatch.setattr(refactor, "resolved_threads_on_github", + lambda repo, pr: {"PRRT_a"}) + # 範囲には fix111 の他に申告されていない stray999 が含まれる + monkeypatch.setattr( + refactor, "commits_in_range", + lambda work, base, head: ["fix111", "stray999"], + ) + calls = _no_git(refactor, monkeypatch) + refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + state = read_state(state_path) + assert state["rounds"][0]["reviews"][0]["findings"][0]["resolved"] is False + assert "fix111" not in state["items"][0]["commits"] + reverted = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + assert "stray999" in reverted and "fix111" in reverted + assert any(c[:2] == ["git", "push"] for c in calls) + + @pytest.mark.parametrize("broken_ids", ["文字列", 123, True, {"a": 1}]) def test_broken_fix_result_does_not_crash( refactor, tmp_path, env_tmp_dir, monkeypatch, broken_ids diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 8bce5f3..5f23eb2 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -671,6 +671,69 @@ def _unposted_reviewers( return names, problems +def _validate_review_payload(name: str, review: Any) -> Optional[str]: + """review の存在と dict 型を確認する。 + + 出力の形が崩れていても落ちないようにする。相手は LLM なので、 + 期待した型で返ってこないことがある。崩れていたら差し戻す。 + """ + if not review: + return f"{name} のレビュー結果がありません" + if not isinstance(review, dict): + return f"{name} のレビュー結果が JSON オブジェクトではありません" + return None + + +def _validate_review_verdict_and_posting(name: str, review: dict[str, Any]) -> list[str]: + """verdict と review_url/post_error を検証する。 + + **投稿できていない判定は採らない。** 投稿が失敗しても結果ファイルの + 判定だけは残るため、そのまま採ると実装担当が読むべき指摘が + Pull Request に無いまま収束する。 + """ + problems: list[str] = [] + verdict = review.get("verdict") + if verdict not in {"APPROVE", "REQUEST_CHANGES"}: + problems.append( + f"{name} の判定 `{verdict}` は APPROVE / REQUEST_CHANGES のいずれかで" + "なければなりません(判定に COMMENT は使いません。" + "投稿の event とは別物です)" + ) + post_error = review.get("post_error") + if post_error: + problems.append(f"{name} がレビューを投稿できませんでした: {post_error}") + elif not review.get("review_url"): + problems.append( + f"{name} のレビュー URL がありません(投稿できていない可能性があります)" + ) + return problems + + +def _validate_review_findings( + name: str, findings: Any, round_items: list[str] +) -> list[str]: + """findings 配列と各要素の item_id を検証する。""" + problems: list[str] = [] + if not isinstance(findings, list): + return [f"{name} の findings が配列ではありません"] + for i, finding in enumerate(findings): + if not isinstance(finding, dict): + problems.append( + f"{name} の指摘 {i + 1} が JSON オブジェクトではありません" + ) + continue + if "item_id" not in finding: + problems.append(f"{name} の指摘 {i + 1} に item_id がありません") + continue + item_id = finding["item_id"] + if item_id is not None and item_id not in round_items: + problems.append( + f"{name} の指摘 {i + 1} の item_id `{item_id}` は" + "このラウンドの改善項目ではありません" + ) + return problems + + def judge( reviews: dict[str, dict[str, Any]], reviewers: list[str], round_items: list[str] ) -> tuple[str, list[str]]: @@ -683,53 +746,17 @@ def judge( そのラウンドに無い ID や欠落は判定に使えない。ラウンド全体に対する指摘は `null` を明示させ、取り消し時はラウンド全件の対象とする。 """ - # 出力の形が崩れていても落ちないようにする。相手は LLM なので、 - # 期待した型で返ってこないことがある。崩れていたら差し戻す。 problems: list[str] = [] for name in reviewers: review = reviews.get(name) - if not review: - problems.append(f"{name} のレビュー結果がありません") + payload_problem = _validate_review_payload(name, review) + if payload_problem: + problems.append(payload_problem) continue - if not isinstance(review, dict): - problems.append(f"{name} のレビュー結果が JSON オブジェクトではありません") - continue - verdict = review.get("verdict") - if verdict not in {"APPROVE", "REQUEST_CHANGES"}: - problems.append( - f"{name} の判定 `{verdict}` は APPROVE / REQUEST_CHANGES のいずれかで" - "なければなりません(判定に COMMENT は使いません。" - "投稿の event とは別物です)" - ) - # **投稿できていない判定は採らない。** 投稿が失敗しても結果ファイルの - # 判定だけは残るため、そのまま採ると実装担当が読むべき指摘が - # Pull Request に無いまま収束する。 - post_error = review.get("post_error") - if post_error: - problems.append(f"{name} がレビューを投稿できませんでした: {post_error}") - elif not review.get("review_url"): - problems.append( - f"{name} のレビュー URL がありません(投稿できていない可能性があります)" - ) - findings = review.get("findings") or [] - if not isinstance(findings, list): - problems.append(f"{name} の findings が配列ではありません") - continue - for i, finding in enumerate(findings): - if not isinstance(finding, dict): - problems.append( - f"{name} の指摘 {i + 1} が JSON オブジェクトではありません" - ) - continue - if "item_id" not in finding: - problems.append(f"{name} の指摘 {i + 1} に item_id がありません") - continue - item_id = finding["item_id"] - if item_id is not None and item_id not in round_items: - problems.append( - f"{name} の指摘 {i + 1} の item_id `{item_id}` は" - "このラウンドの改善項目ではありません" - ) + problems.extend(_validate_review_verdict_and_posting(name, review)) + problems.extend( + _validate_review_findings(name, review.get("findings") or [], round_items) + ) if problems: return "invalid", problems if all((reviews[name] or {}).get("verdict") == "APPROVE" for name in reviewers): @@ -1154,30 +1181,14 @@ def cmd_start_round(args: argparse.Namespace) -> None: ) -def cmd_merge_proposals(args: argparse.Namespace) -> None: - """Step 3 — 提案をマージして改善項目を作る。 - - 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 +def _load_runtime_proposals( + state: dict[str, Any], entry: dict[str, Any] +) -> dict[str, list[dict[str, Any]]]: + """各ランタイムの提案結果ファイルを読み込み、JSON を解析する。 - **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる - ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + 1 者が結果ファイルを欠かした・壊れた JSON を返した場合も、その者の提案を + 無かったものとして扱い、全体の統合は続ける。 """ - path, state = _load(args.id) - entry = _current_round(state) - - if entry.get("proposal_keys") is not None: - info( - f"↻ 提案ラウンド {entry['round']} は統合済みです" - f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" - ) - for item_id in entry.get("items", []): - item = _find_item(state, item_id, required=False) - if item is not None: - info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") - if not entry.get("adopted"): - sys.exit(2) - return - proposals: dict[str, list[dict[str, Any]]] = {} for runtime in state["runtimes"]: result = _result_path( @@ -1206,17 +1217,17 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: proposals[runtime] = [i for i in items if isinstance(i, dict)] \ if isinstance(items, list) else [] entry["proposed"][runtime] = len(proposals[runtime]) + return proposals - excluded = { - (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] - } - adopted, deferred = merge_proposals( - proposals, - threshold=state["severity_threshold"], - max_items=state["max_items_per_round"], - excluded_keys=excluded, - ) +def _update_state_from_merged_proposals( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + adopted: list[dict[str, Any]], + deferred: list[dict[str, Any]], +) -> None: + """統合済みの提案から状態オブジェクトを更新し、次回ラウンドの起点を準備する。""" # 収束判定に使う「前ラウンドとの重複率」。見送りも含めた提案全体で測る。 current_keys = [(i["path"], i["symbol"], i["smell"]) for i in adopted + deferred] entry["proposal_keys"] = [list(k) for k in current_keys] @@ -1250,6 +1261,45 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: state["final"] = "no_more_proposals" state["ended_at"] = statefile.now() statefile.save(path, state) + + +def cmd_merge_proposals(args: argparse.Namespace) -> None: + """Step 3 — 提案をマージして改善項目を作る。 + + 終了コード: 0 = 採用あり / 2 = 採用 0 件(提案ラウンドの繰り返しを終える)。 + + **同じラウンドで叩き直しても二重に項目を作らない。** 進行を止めても再開できる + ことが前提なので、統合済みなら前回と同じ結果をそのまま返す。 + """ + path, state = _load(args.id) + entry = _current_round(state) + + if entry.get("proposal_keys") is not None: + info( + f"↻ 提案ラウンド {entry['round']} は統合済みです" + f"(採用 {entry.get('adopted', 0)} 件 / 見送り {entry.get('deferred', 0)} 件)" + ) + for item_id in entry.get("items", []): + item = _find_item(state, item_id, required=False) + if item is not None: + info(f" {item_id} [{item['severity']}] {item['path']}#{item['symbol']}") + if not entry.get("adopted"): + sys.exit(2) + return + + proposals = _load_runtime_proposals(state, entry) + + excluded = { + (d["path"], d["symbol"], d["smell"]) for d in state["deferred_items"] + } + adopted, deferred = merge_proposals( + proposals, + threshold=state["severity_threshold"], + max_items=state["max_items_per_round"], + excluded_keys=excluded, + ) + + _update_state_from_merged_proposals(path, state, entry, adopted, deferred) info( f"提案 {sum(entry['proposed'].values())} 件 → 統合 {entry['merged']} 件 → " f"採用 {entry['adopted']} 件 / 見送り {entry['deferred']} 件" @@ -1426,27 +1476,18 @@ def _collect_apply_reports( return reported, unknown_ids -def _validate_apply_commit_ownership( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - args: argparse.Namespace, - reported: dict[str, dict[str, Any]], - unknown_ids: list[str], - work: pathlib.Path, - ordered_range: list[str], - in_range: set[str], - head_sha: str, -) -> None: - # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** - # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま - # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 - # **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 - # 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと - # 実際の差分が食い違い、どちらが正しいか決められなくなる。 - # - # 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 - # 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 +def _detect_commit_owners( + work: pathlib.Path, reported: dict[str, dict[str, Any]] +) -> tuple[dict[str, str], list[str]]: + """申告コミットを完全 SHA へ正規化し、所有項目と重複申告を特定する。 + + **1 コミットの所有項目は 1 つだけ。** 同じコミットを 2 つの項目が申告すると、 + 片方が失敗して取り消したときに、もう片方は成功のまま残る。状態ファイルと + 実際の差分が食い違い、どちらが正しいか決められなくなる。 + + 判定は**完全な SHA へ正規化してから**行う。申告の文字列をそのまま鍵にすると、 + 一方が完全 SHA、他方が短縮 SHA で同じコミットを指したときに重複を見逃す。 + """ owner_of: dict[str, str] = {} duplicated: list[str] = [] for item_id, r in reported.items(): @@ -1457,11 +1498,13 @@ def _validate_apply_commit_ownership( if full in owner_of and owner_of[full] != item_id: duplicated.append(full) owner_of.setdefault(full, item_id) + return owner_of, duplicated - unassigned = sorted(in_range - set(owner_of)) - if not (unassigned or unknown_ids or duplicated): - return +def _build_ownership_error_reason( + unassigned: list[str], unknown_ids: list[str], duplicated: list[str] +) -> str: + """所有権検査の失敗理由を組み立てる。""" causes = [] if unassigned: causes.append( @@ -1478,16 +1521,26 @@ def _validate_apply_commit_ownership( f"複数の項目が同じコミットを申告しています" f"({', '.join(s[:7] for s in duplicated[:5])})" ) - reason = ( + return ( "、".join(causes) + "。検証を回避した変更や、状態と実差分の食い違いを Pull Request に" "残さないため、ラウンドごと取り消します" ) - info(f"❌ {reason}") - for item_id in entry["items"]: - it = _find_item(state, item_id) - it["status"] = "abandoned" - it["failure_reason"] = reason + + +def _revert_unverified_apply_round( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + work: pathlib.Path, + ordered_range: list[str], + unassigned: list[str], + unknown_ids: list[str], + duplicated: list[str], + head_sha: str, +) -> None: + """検証を通らない適用ラウンドの範囲を取り消し、状態と公開を反映する。""" # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 # 起点まで戻すのが最も確実である。順序は `_revert_item_commits` が # git の履歴から決め直す。 @@ -1524,6 +1577,39 @@ def _validate_apply_commit_ownership( _push_head(state) entry["pending_push"] = False statefile.save(path, state) + + +def _validate_apply_commit_ownership( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + args: argparse.Namespace, + reported: dict[str, dict[str, Any]], + unknown_ids: list[str], + work: pathlib.Path, + ordered_range: list[str], + in_range: set[str], + head_sha: str, +) -> None: + # **範囲のコミットは全て、いずれかの改善項目に割り当てられていること。** + # 申告から漏れたコミットはテストもトレーラーも差分予算も検査されず、そのまま + # Pull Request に残る。都合の悪い変更を申告しないだけで検査を回避できてしまう。 + owner_of, duplicated = _detect_commit_owners(work, reported) + + unassigned = sorted(in_range - set(owner_of)) + if not (unassigned or unknown_ids or duplicated): + return + + reason = _build_ownership_error_reason(unassigned, unknown_ids, duplicated) + info(f"❌ {reason}") + for item_id in entry["items"]: + it = _find_item(state, item_id) + it["status"] = "abandoned" + it["failure_reason"] = reason + _revert_unverified_apply_round( + path, state, entry, args, work, ordered_range, + unassigned, unknown_ids, duplicated, head_sha, + ) sys.exit(2) @@ -1979,49 +2065,50 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: statefile.save(path, state) -def cmd_merge_fix(args: argparse.Namespace) -> None: - """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" - path, state = _load(args.id) - entry = _round(state, args.round) - _discard_impl_leftovers(state, state["worktrees"]["work"]) - _flush_pending_push(path, state, entry) - impl = entry["impl"] - result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) - payload = _read_result(result, impl) +def _fix_merge_key(entry: dict[str, Any], result: pathlib.Path) -> str: + """修正結果の取り込み済み判定に使う鍵を作る。 - # **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 - # 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 - # 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 - # 鍵は**試行番号と結果ファイルの内容**から作る。 - # - # - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず - # 同じ申告を再処理してしまう。 - # - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ - # 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま - # 同じ修正を起動し続ける。 - # - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に - # なりうる。 - # - # 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が - # **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 - work = state["worktrees"]["work"] - head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" + **叩き直しても二重に取り込まない。** 修正は同じラウンドで何度も回るため、 + 「このラウンドで処理済みか」では判定できない。**入力が前回と同じか**で見る。 + 次の修正ラウンドでは結果ファイルが上書きされ、HEAD も進むので鍵が変わる。 + 鍵は**試行番号と結果ファイルの内容**から作る。 + + - HEAD は混ぜない。検証に失敗して取り消すと HEAD が変わるため、鍵が一致せず + 同じ申告を再処理してしまう。 + - 内容だけでも足りない。次の修正ラウンドが同じ JSON(コミットなし・同じ + 未解決 ID など)を返すと過去のラウンドと衝突し、`fix_rounds` が進まないまま + 同じ修正を起動し続ける。 + - ファイルの更新時刻も使わない。粒度が環境によって違い、書き直しても同じ値に + なりうる。 + + 修正の前には必ず `judge-review` が走るので、そこで進めた試行番号が + **実行単位の識別子**になる。叩き直しただけなら番号は変わらない。 + """ attempt = entry.get("fix_attempts", 0) - merge_key = ( - f"{attempt}:" - + hashlib.sha256(result.read_bytes()).hexdigest() - ) + return f"{attempt}:" + hashlib.sha256(result.read_bytes()).hexdigest() + + +def _already_merged_fix_result( + entry: dict[str, Any], merge_key: str +) -> bool: + """この修正結果を取り込み済みなら真を返し、鍵の一覧を更新する。""" merged_keys = entry.setdefault("fix_merged_keys", []) if merge_key in merged_keys: info( f"↻ この修正結果は取り込み済みです" f"(修正ラウンド {entry['fix_rounds']})" ) - return + return True + return False - # 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と - # 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の - # `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + +def _resolved_fix_thread_ids(payload: dict[str, Any], repo: str, pr: int) -> set[str]: + """自己申告と GitHub 側の解決状態を突き合わせ、両方が解決と言う ID だけ返す。 + + 自己申告をそのまま信じない。解決 API に失敗・未実行でも「解決済み」と + 書けてしまい、未解決の指摘が取り消し対象から外れる。GitHub 側の + `isResolved` と突き合わせ、**両方が解決と言っているものだけ**を反映する。 + """ raw_claimed = payload.get("resolved_thread_ids") # 文字列は 1 文字ずつに分解され、数値や真偽値は反復できずに落ちる。 # **配列であることを先に確かめる。** @@ -2032,15 +2119,75 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if raw_claimed is not None and not isinstance(raw_claimed, list): info(f"⚠ resolved_thread_ids が配列ではありません({type(raw_claimed).__name__})。" "解決の申告は無かったものとして扱います") - actual = resolved_threads_on_github(state["repo"], state["current_pr"]) + actual = resolved_threads_on_github(repo, pr) if actual is None: info("⚠ レビュースレッドの解決状態を取得できませんでした。" "自己申告は採用せず、未解決のまま扱います") - resolved: set[str] = set() - else: - resolved = claimed & actual - for thread_id in sorted(claimed - actual): - info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return set() + resolved = claimed & actual + for thread_id in sorted(claimed - actual): + info(f"⚠ {thread_id} は解決済みと申告されましたが、GitHub では未解決です") + return resolved + + +def _unassigned_fix_commits( + work: str, reported_shas: list[str], ordered_range: list[str] +) -> list[str]: + """範囲内のコミットのうち、どの申告にも含まれていないものを返す。 + + 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 + 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 + """ + reported_full = { + full for full in ( + _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) + for s in reported_shas + ) if full + } + return sorted(set(ordered_range) - reported_full) + + +def _verify_fix_commits( + facts: list[dict[str, Any]], scope: list[str] +) -> tuple[list[str], list[tuple[str, str]]]: + """修正コミットを検証し、問題点の一覧と受理した (item_id, sha) を返す。 + + **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** + 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける + (見送りの対象にもならない)。どのコミットが安全かは決められないので、 + 適用フェーズの未割当コミットと同じ扱いにする。 + """ + problems: list[str] = [] + accepted: list[tuple[str, str]] = [] # (item_id, sha) + for commit in facts: + item_id = (commit.get("trailers") or {}).get("Item-Id") + problem = verify_fix_commit(commit, scope) + if problem: + problems.append(problem) + info(f"❌ 修正コミットが手順を満たしていません: {problem}") + continue + accepted.append((item_id, commit["sha"])) + return problems, accepted + + +def cmd_merge_fix(args: argparse.Namespace) -> None: + """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" + path, state = _load(args.id) + entry = _round(state, args.round) + _discard_impl_leftovers(state, state["worktrees"]["work"]) + _flush_pending_push(path, state, entry) + impl = entry["impl"] + result = _result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) + payload = _read_result(result, impl) + + work = state["worktrees"]["work"] + head_now = _git_out(work, ["rev-parse", "HEAD"]) or "" + merge_key = _fix_merge_key(entry, result) + if _already_merged_fix_result(entry, merge_key): + return + merged_keys = entry["fix_merged_keys"] + + resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) # 修正コミットも適用と同じ基準で、**git と実際のテスト実行から**検証する。 # 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 @@ -2062,16 +2209,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: code=2, ) reported_shas = _reported_shas(payload) - - # 適用と同じく、**範囲のコミットは全て申告されていること**を求める。 - # 申告から漏れた修正コミットは検証を受けないまま Pull Request に残る。 - reported_full = { - full for full in ( - _git_out(work, ["rev-parse", "--verify", f"{s}^{{commit}}"]) - for s in reported_shas - ) if full - } - unassigned = sorted(set(ordered_range) - reported_full) + unassigned = _unassigned_fix_commits(work, reported_shas, ordered_range) facts = collect_commit_facts( work, reported_shas, set(ordered_range), @@ -2079,20 +2217,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) - # **不正なコミットが 1 件でもあれば、修正ラウンドの範囲ごと取り消す。** - # 状態を記録しないだけでは、未検証の変更が Pull Request に残り続ける - # (見送りの対象にもならない)。どのコミットが安全かは決められないので、 - # 適用フェーズの未割当コミットと同じ扱いにする。 - problems: list[str] = [] - accepted: list[tuple[str, str]] = [] # (item_id, sha) - for commit in facts: - item_id = (commit.get("trailers") or {}).get("Item-Id") - problem = verify_fix_commit(commit, state.get("target_scope") or []) - if problem: - problems.append(problem) - info(f"❌ 修正コミットが手順を満たしていません: {problem}") - continue - accepted.append((item_id, commit["sha"])) + problems, accepted = _verify_fix_commits(facts, state.get("target_scope") or []) if unassigned: info( @@ -2721,49 +2846,24 @@ def _commit_owner( 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 = [ +def _pending_drop_item_ids(state: dict[str, Any], drop_ids: list[str]) -> list[str]: + """drop_ids から、まだ取り消されていない項目 ID だけを返す。""" + return [ 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} +def _drop_replay_plan( + state: dict[str, Any], entry: dict[str, Any], pending: list[str], + ordered: list[str], +) -> tuple[dict[str, str], list[str], list[str]]: + """残す項目 (`keep_ids`) と積み直す SHA (`replay`) を求める。 + + `ordered` は新しい順なので、積み直しは反転して古い順にする。 + **どの項目にも属さないコミット(過去の取り消しなど)は積み直さない。** + """ + work = state["worktrees"]["work"] owner = _commit_owner(work, state, entry) drop = set(pending) keep_ids = [ @@ -2771,30 +2871,55 @@ def _drop_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] + return owner, keep_ids, replay + + +def _dry_run_drop_plan( + pending: list[str], ordered: list[str], replay: list[str] +) -> dict[str, Any]: + """dry-run 時の出力と戻り値を作る。実際の revert/cherry-pick は行わない。""" + 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)} - 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)} +def _execute_drop_replay( + work: str, ordered: list[str], head: Optional[str], replay: list[str], +) -> tuple[dict[str, str], str]: + """範囲を取り消して残す項目を積み直す。積み直しに失敗したら round モードへ退避する。 + + 戻り値は `(mapping, mode)`。`mapping` は積み直し後の SHA 対応 + (`round` モードでは空)。 + """ _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("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが # 2 組できて履歴が無駄に汚れる。積み直す前の地点へ戻すだけでよい。 _reset_hard(work, reverted_head) - mapping, mode = {}, "round" + return {}, "round" + return mapping, "item" + +def _record_drop_result( + state: dict[str, Any], + entry: dict[str, Any], + pending: list[str], + keep_ids: list[str], + ordered: list[str], + replay: list[str], + owner: dict[str, str], + mapping: dict[str, str], + mode: str, +) -> dict[str, Any]: + """item の reverted/commits と entry.drops を更新し、結果を返す。""" dropped = list(entry["items"]) if mode == "round" else pending for item_id in entry["items"]: item = _find_item(state, item_id, required=False) @@ -2819,6 +2944,58 @@ def _drop_items( "reverted": len(ordered), "replayed": len(mapping)} +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 = _pending_drop_item_ids(state, drop_ids) + 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, keep_ids, replay = _drop_replay_plan(state, entry, pending, ordered) + + if dry_run: + return _dry_run_drop_plan(pending, ordered, replay) + + mapping, mode = _execute_drop_replay(work, ordered, head, replay) + + return _record_drop_result( + state, entry, pending, keep_ids, ordered, replay, owner, mapping, mode, + ) + + 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 5101513..2ed8f26 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 @@ -387,6 +387,54 @@ def fake_run(cmd, **kw): assert any("cursor=C1" in "".join(c) for c in calls) +def test_merge_fix_skips_already_merged_result( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """同じ試行番号・同じ結果ファイルの内容は二重に取り込まない。 + + 叩き直しても鍵(試行番号 + 結果ファイルのハッシュ)が変わらなければ、 + fix_rounds を進めず即座に戻る。 + """ + state_path = _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + state = read_state(state_path) + result_path = state_path.parent / "codex-fix-r1-result.json" + import hashlib as _hashlib + merge_key = "1:" + _hashlib.sha256(result_path.read_bytes()).hexdigest() + state["rounds"][0]["fix_merged_keys"] = [merge_key] + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + monkeypatch.setattr(refactor, "resolved_threads_on_github", + lambda repo, pr: {"PRRT_a"}) + refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + after = read_state(state_path) + # fix_rounds は進まず、解決も反映されない(早期に戻ったため) + assert after["rounds"][0]["fix_rounds"] == 0 + assert after["rounds"][0]["reviews"][0]["findings"][0]["resolved"] is False + + +def test_merge_fix_rejects_commits_missing_from_the_claim( + refactor, tmp_path, env_tmp_dir, monkeypatch +): + """範囲に含まれるが誰の申告にも無いコミットがあれば、範囲ごと取り消す。""" + state_path = _prepare_fix(refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + monkeypatch.setattr(refactor, "resolved_threads_on_github", + lambda repo, pr: {"PRRT_a"}) + # 範囲には fix111 の他に申告されていない stray999 が含まれる + monkeypatch.setattr( + refactor, "commits_in_range", + lambda work, base, head: ["fix111", "stray999"], + ) + calls = _no_git(refactor, monkeypatch) + refactor.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + state = read_state(state_path) + assert state["rounds"][0]["reviews"][0]["findings"][0]["resolved"] is False + assert "fix111" not in state["items"][0]["commits"] + reverted = [c[-1] for c in calls if c[:2] == ["git", "revert"]] + assert "stray999" in reverted and "fix111" in reverted + assert any(c[:2] == ["git", "push"] for c in calls) + + @pytest.mark.parametrize("broken_ids", ["文字列", 123, True, {"a": 1}]) def test_broken_fix_result_does_not_crash( refactor, tmp_path, env_tmp_dir, monkeypatch, broken_ids From 107be5e6b20bd6601efc9a12682ea45b45f1d4ac Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:24:57 +0000 Subject: [PATCH 63/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Finit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_init に直書きしていた GitHub からの Pull Request メタデータ取得 (viewer / author / headRefName / baseRefName の 4 回の gh 呼び出しと is_own_pr の判定)を _fetch_pr_context として抽出した。cmd_init は 戻り値を受け取るだけになり、他の段階(_ensure_work_worktree など)と 同じ粒度で段階の切れ目が読めるようになる。振る舞いは変えていない。 Item-Id: R3-001 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 5f23eb2..35727a9 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -895,6 +895,28 @@ def _warn_unmeasurable_models( ) +def _fetch_pr_context(pr: int) -> tuple[str, str, bool, str]: + """GitHub から Pull Request のメタデータを取り、自分の Pull Request かを判定する。 + + 返すのは `(base_branch, head_branch, is_own_pr, author)`。 + """ + # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は + # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの + # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 + viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) + author = _sh( + ["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"] + ) + is_own_pr = bool(viewer) and viewer == author + head_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"] + ) + base_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"] + ) + return base_branch, head_branch, is_own_pr, author + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -923,22 +945,9 @@ def cmd_init(args: argparse.Namespace) -> None: auth = check_auth(sorted(set(runtimes) | set(impl_capable))) repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) - # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は - # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの - # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 - viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) - author = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "author", "--jq", ".author.login"] - ) - is_own_pr = bool(viewer) and viewer == author + base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) if is_own_pr: info(f"⚠ 自分の Pull Request です(作成者 {author})— 投稿は COMMENT へ倒します") - head_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] - ) - base_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "baseRefName", "--jq", ".baseRefName"] - ) root = ( pathlib.Path(args.worktree_root).resolve() if args.worktree_root From 6eac88268e30808f1d126fcfb2be2516afd6827c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:25:57 +0000 Subject: [PATCH 64/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#=5Fsync=5Fgenerated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同期コマンドの実行と失敗時の破棄・中断を _run_sync_command として 切り出した。_sync_generated 側は 1 行の呼び出しになり、パイプラインの 段が名前で読めるようになる。振る舞いは変えていない。 Item-Id: R3-002 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 35727a9..ea5e63a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -3142,6 +3142,29 @@ def _require_clean_worktree(state: dict[str, Any], work: str) -> None: ) +def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: + """同期コマンドを実行する。失敗したら差分を捨てて中断する。 + + **黙って push しない。** 同期できない状態を公開すると、利用者のリポジトリの + 検査を壊したまま進むことになる。 + """ + code, timed_out = _run_with_timeout( + command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + ) + if not (timed_out or code != 0): + return + # **途中まで書き換えた差分を残さない。** 残すと次の実行は + # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が + # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 + # ここにある変更は全て同期が作ったものだと分かる。 + _discard_worktree_changes(work) + die( + f"生成物の同期に失敗しました({command}): " + + ("打ち切りました" if timed_out else f"終了コード {code}") + + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" + ) + + def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3163,20 +3186,7 @@ def _sync_generated(state: dict[str, Any]) -> None: # **同期の前に作業ツリーが綺麗であることを求める。** 汚れたまま同期すると、 # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) - code, timed_out = _run_with_timeout( - command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) - ) - if timed_out or code != 0: - # **途中まで書き換えた差分を残さない。** 残すと次の実行は - # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が - # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 - # ここにある変更は全て同期が作ったものだと分かる。 - _discard_worktree_changes(work) - die( - f"生成物の同期に失敗しました({command}): " - + ("打ち切りました" if timed_out else f"終了コード {code}") - + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" - ) + _run_sync_command(state, work, command) produced = _dirty_paths(state, work) if not produced: return From beed674c4e935bea6bf4caf23c0e160e439a27b1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:26:46 +0000 Subject: [PATCH 65/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#=5Fsync=5Fgenerated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成差分の git add / commit と失敗時の破棄を _commit_sync_changes として 切り出した。_sync_generated は command 判定 → 清浄性確認 → 実行 → 差分検出 → コミット の呼び出し列だけになる。振る舞いは変えていない。 Item-Id: R3-002 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ea5e63a..2bdff3e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -3165,6 +3165,28 @@ def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: ) +def _commit_sync_changes(work: str, command: str, produced: list[str]) -> None: + """同期が作った差分を進行側のコミットとして積む。差分が無ければ何もしない。 + + このコミットはどの改善項目にも属さない。取り消しでは積み直されないが、 + 次の push で作り直されるので失われても問題にならない。 + """ + if not produced: + return + # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で + # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で + # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は + # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを + # 確認済みだからである。 + try: + _sh(["git", "add", "--", *produced], cwd=work) + _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) + except SystemExit: + _discard_worktree_changes(work) + raise + info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + + def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3187,21 +3209,7 @@ def _sync_generated(state: dict[str, Any]) -> None: # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) _run_sync_command(state, work, command) - produced = _dirty_paths(state, work) - if not produced: - return - # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で - # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で - # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は - # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを - # 確認済みだからである。 - try: - _sh(["git", "add", "--", *produced], cwd=work) - _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) - except SystemExit: - _discard_worktree_changes(work) - raise - info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + _commit_sync_changes(work, command, _dirty_paths(state, work)) def _push_head(state: dict[str, Any]) -> None: From 299043c242bb15c8f0c6bae3a442217779003dd0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:28:18 +0000 Subject: [PATCH 66/90] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#=5Fhandle=5Freview=5Fverdict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verdict == invalid の入れ子を _handle_invalid_review_verdict へ抽出した。 _handle_review_verdict には invalid / approved / changes の 3 分岐だけが 残り、レビュー判定の主流が読めるようになる。振る舞いは変えていない。 Item-Id: R3-003 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 114 ++++++++++-------- 1 file changed, 67 insertions(+), 47 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2bdff3e..2e3a370 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1870,6 +1870,70 @@ def _remember(exit_code: int) -> None: ) +def _handle_invalid_review_verdict( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + unposted: list[str], + problems: list[str], + record: dict[str, Any], + remember: Callable[[int], None], +) -> None: + """形式を満たさないレビュー結果を、差し戻し・中断・変更要求のどれかへ振り分ける。 + + この関数から通常の復帰はしない。必ず `sys.exit` か `die` で抜ける。 + """ + for p in problems: + info(f"❌ {p}") + entry["invalid_reviews"] = entry.get("invalid_reviews", 0) + 1 + if entry["invalid_reviews"] > MAX_INVALID_REVIEWS: + # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは + # レビュー担当のプロセスが仕事をしなかったということで、実装担当が + # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された + # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 + missing = [name for name in reviewers if name not in reviews] + # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても + # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 + blocked = missing + [name for name in unposted if name not in missing] + if blocked: + remember(ABORT) + statefile.save(path, state) + die( + f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" + "(結果ファイルの欠落、または投稿の失敗)。" + "実装担当への指摘ではないため、進行を中断します。" + "原因を直して同じコマンド列を叩き直せば再開できます" + ) + # 差し戻しを無限に繰り返さない。形式を満たせないレビューが続く以上、 + # このラウンドの成果は検証されていないものとして扱い、変更要求へ落とす。 + # 紐づけ先が決まらないので、取り消しはラウンド全件が対象になる。 + record["findings"].append({ + "reviewer": "cross-refactoring", + "item_id": None, + "thread_id": None, + "summary": ( + f"レビュー結果の形式が {MAX_INVALID_REVIEWS + 1} 回続けて不正だった: " + + " / ".join(problems) + ), + "resolved": False, + }) + # **この出口も修正フェーズの起点を記録する。** 記録せずに変更要求を + # 返すと `merge-fix` が範囲を確定できずに弾かれ、`fix_rounds` が + # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 + # 上限へ永久に到達せず修正と再レビューを往復し続ける。 + _prepare_fix_phase(state, entry) + remember(2) + statefile.save(path, state) + info("差し戻しの上限に達したため、変更要求として扱います") + sys.exit(2) + remember(3) + statefile.save(path, state) + info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") + sys.exit(3) + + def _handle_review_verdict( path: pathlib.Path, state: dict[str, Any], @@ -1883,53 +1947,9 @@ def _handle_review_verdict( remember: Callable[[int], None], ) -> None: if verdict == "invalid": - for p in problems: - info(f"❌ {p}") - entry["invalid_reviews"] = entry.get("invalid_reviews", 0) + 1 - if entry["invalid_reviews"] > MAX_INVALID_REVIEWS: - # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは - # レビュー担当のプロセスが仕事をしなかったということで、実装担当が - # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された - # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 - missing = [name for name in reviewers if name not in reviews] - # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても - # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 - blocked = missing + [name for name in unposted if name not in missing] - if blocked: - remember(ABORT) - statefile.save(path, state) - die( - f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" - "(結果ファイルの欠落、または投稿の失敗)。" - "実装担当への指摘ではないため、進行を中断します。" - "原因を直して同じコマンド列を叩き直せば再開できます" - ) - # 差し戻しを無限に繰り返さない。形式を満たせないレビューが続く以上、 - # このラウンドの成果は検証されていないものとして扱い、変更要求へ落とす。 - # 紐づけ先が決まらないので、取り消しはラウンド全件が対象になる。 - record["findings"].append({ - "reviewer": "cross-refactoring", - "item_id": None, - "thread_id": None, - "summary": ( - f"レビュー結果の形式が {MAX_INVALID_REVIEWS + 1} 回続けて不正だった: " - + " / ".join(problems) - ), - "resolved": False, - }) - # **この出口も修正フェーズの起点を記録する。** 記録せずに変更要求を - # 返すと `merge-fix` が範囲を確定できずに弾かれ、`fix_rounds` が - # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 - # 上限へ永久に到達せず修正と再レビューを往復し続ける。 - _prepare_fix_phase(state, entry) - remember(2) - statefile.save(path, state) - info("差し戻しの上限に達したため、変更要求として扱います") - sys.exit(2) - remember(3) - statefile.save(path, state) - info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") - sys.exit(3) + _handle_invalid_review_verdict( + path, state, entry, reviewers, reviews, unposted, problems, record, remember, + ) if verdict == "approved": for item_id in entry["apply"]["applied"]: _find_item(state, item_id)["status"] = "done" From 369bffa4882cba901f6f7e07542f9e144b58feeb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:29:20 +0000 Subject: [PATCH 67/90] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#=5Fhandle=5Finvalid=5Freview=5Fverdict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上限未満の差し戻しを先に判定して抜ける形にし、上限超過の処理を 入れ子から出した。中断・変更要求の判断が同じ深さに並ぶ。 振る舞いは変えていない。 Item-Id: R3-003 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2e3a370..9678438 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1888,50 +1888,50 @@ def _handle_invalid_review_verdict( for p in problems: info(f"❌ {p}") entry["invalid_reviews"] = entry.get("invalid_reviews", 0) + 1 - if entry["invalid_reviews"] > MAX_INVALID_REVIEWS: - # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは - # レビュー担当のプロセスが仕事をしなかったということで、実装担当が - # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された - # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 - missing = [name for name in reviewers if name not in reviews] - # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても - # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 - blocked = missing + [name for name in unposted if name not in missing] - if blocked: - remember(ABORT) - statefile.save(path, state) - die( - f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" - "(結果ファイルの欠落、または投稿の失敗)。" - "実装担当への指摘ではないため、進行を中断します。" - "原因を直して同じコマンド列を叩き直せば再開できます" - ) - # 差し戻しを無限に繰り返さない。形式を満たせないレビューが続く以上、 - # このラウンドの成果は検証されていないものとして扱い、変更要求へ落とす。 - # 紐づけ先が決まらないので、取り消しはラウンド全件が対象になる。 - record["findings"].append({ - "reviewer": "cross-refactoring", - "item_id": None, - "thread_id": None, - "summary": ( - f"レビュー結果の形式が {MAX_INVALID_REVIEWS + 1} 回続けて不正だった: " - + " / ".join(problems) - ), - "resolved": False, - }) - # **この出口も修正フェーズの起点を記録する。** 記録せずに変更要求を - # 返すと `merge-fix` が範囲を確定できずに弾かれ、`fix_rounds` が - # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 - # 上限へ永久に到達せず修正と再レビューを往復し続ける。 - _prepare_fix_phase(state, entry) - remember(2) + if entry["invalid_reviews"] <= MAX_INVALID_REVIEWS: + remember(3) statefile.save(path, state) - info("差し戻しの上限に達したため、変更要求として扱います") - sys.exit(2) - remember(3) + info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") + sys.exit(3) + # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは + # レビュー担当のプロセスが仕事をしなかったということで、実装担当が + # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された + # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 + missing = [name for name in reviewers if name not in reviews] + # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても + # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 + blocked = missing + [name for name in unposted if name not in missing] + if blocked: + remember(ABORT) + statefile.save(path, state) + die( + f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" + "(結果ファイルの欠落、または投稿の失敗)。" + "実装担当への指摘ではないため、進行を中断します。" + "原因を直して同じコマンド列を叩き直せば再開できます" + ) + # 差し戻しを無限に繰り返さない。形式を満たせないレビューが続く以上、 + # このラウンドの成果は検証されていないものとして扱い、変更要求へ落とす。 + # 紐づけ先が決まらないので、取り消しはラウンド全件が対象になる。 + record["findings"].append({ + "reviewer": "cross-refactoring", + "item_id": None, + "thread_id": None, + "summary": ( + f"レビュー結果の形式が {MAX_INVALID_REVIEWS + 1} 回続けて不正だった: " + + " / ".join(problems) + ), + "resolved": False, + }) + # **この出口も修正フェーズの起点を記録する。** 記録せずに変更要求を + # 返すと `merge-fix` が範囲を確定できずに弾かれ、`fix_rounds` が + # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 + # 上限へ永久に到達せず修正と再レビューを往復し続ける。 + _prepare_fix_phase(state, entry) + remember(2) statefile.save(path, state) - info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") - sys.exit(3) + info("差し戻しの上限に達したため、変更要求として扱います") + sys.exit(2) def _handle_review_verdict( From 759a1354b2ea357a968b2103068facdfa92f2a89 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:30:07 +0000 Subject: [PATCH 68/90] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#=5Fhandle=5Finvalid=5Freview=5Fverdict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 結果ファイルの欠落と投稿の失敗をまとめる判定を _blocked_reviewers_without_actionable_findings として抽出した。 中断の条件が名前で読めるようになる。振る舞いは変えていない。 Item-Id: R3-003 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 9678438..6cfd940 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1870,6 +1870,25 @@ def _remember(exit_code: int) -> None: ) +def _blocked_reviewers_without_actionable_findings( + reviewers: list[str], + reviews: dict[str, dict[str, Any]], + unposted: list[str], +) -> list[str]: + """実装担当が読める指摘を残せなかったレビュー担当を挙げる。 + + **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは + レビュー担当のプロセスが仕事をしなかったということで、実装担当が + 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された + 実装担当が空回りし、承認済みの項目まで見送りへ進む。 + + **投稿できなかった担当も同じ扱いにする。** 判定は残っていても + Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 + """ + missing = [name for name in reviewers if name not in reviews] + return missing + [name for name in unposted if name not in missing] + + def _handle_invalid_review_verdict( path: pathlib.Path, state: dict[str, Any], @@ -1893,14 +1912,7 @@ def _handle_invalid_review_verdict( statefile.save(path, state) info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") sys.exit(3) - # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは - # レビュー担当のプロセスが仕事をしなかったということで、実装担当が - # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された - # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 - missing = [name for name in reviewers if name not in reviews] - # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても - # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 - blocked = missing + [name for name in unposted if name not in missing] + blocked = _blocked_reviewers_without_actionable_findings(reviewers, reviews, unposted) if blocked: remember(ABORT) statefile.save(path, state) From af392b37175c0279e4974148976767c97a41bbf9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:31:12 +0000 Subject: [PATCH 69/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証を通らない修正ラウンドの取り消しを _revert_invalid_fix_round へ 抽出した。pending_push の印・取り消し・起点の更新・解決の申告を 採らない判断が 1 か所にまとまる。振る舞いは変えていない。 Item-Id: R3-004 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6cfd940..b4114d6 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2211,6 +2211,41 @@ def _verify_fix_commits( return problems, accepted +def _revert_invalid_fix_round( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> set[str]: + """検証を通らない修正ラウンドの範囲を取り消し、採用する解決スレッドを返す。 + + 取り消した以上、解決の申告も採らないので**常に空集合を返す**。 + """ + work = state["worktrees"]["work"] + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + 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}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return set() + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2267,28 +2302,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - 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}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() + resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) else: for item_id, sha in accepted: item = _find_item(state, item_id, required=False) From 8864dc3aff2b77314ba088182861ba007501365b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:31:54 +0000 Subject: [PATCH 70/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証を通った修正コミットを改善項目へ紐づけるループを _record_accepted_fix_commits へ抽出した。振る舞いは変えていない。 Item-Id: R3-004 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b4114d6..92c4d9a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2211,6 +2211,19 @@ def _verify_fix_commits( return problems, accepted +def _record_accepted_fix_commits( + state: dict[str, Any], accepted: list[tuple[str, str]] +) -> None: + """検証を通った修正コミットを、対応する改善項目へ紐づける。 + + 見送り済みなどで項目が見つからないコミットは、紐づけ先が無いので飛ばす。 + """ + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + + def _revert_invalid_fix_round( path: pathlib.Path, state: dict[str, Any], @@ -2304,10 +2317,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + _record_accepted_fix_commits(state, accepted) for review in entry["reviews"]: for finding in review["findings"]: From 887471c0fd5e29484c9d7f1bc984c059632eed8b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:32:34 +0000 Subject: [PATCH 71/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解決済み thread を指摘へ反映する二重ループを _mark_resolved_fix_findings へ抽出した。cmd_merge_fix は読み込み → 検証 → 記録 → push の流れだけに なる。振る舞いは変えていない。 Item-Id: R3-004 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../skills/cross-refactoring/scripts/refactor.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 92c4d9a..50e7285 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2211,6 +2211,15 @@ def _verify_fix_commits( return problems, accepted +def _mark_resolved_fix_findings(entry: dict[str, Any], resolved: set[str]) -> None: + """GitHub 側で解決済みになった thread に対応する指摘へ、解決の印を付ける。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + def _record_accepted_fix_commits( state: dict[str, Any], accepted: list[tuple[str, str]] ) -> None: @@ -2319,11 +2328,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: else: _record_accepted_fix_commits(state, accepted) - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True + _mark_resolved_fix_findings(entry, resolved) merged_keys.append(merge_key) entry["fix_rounds"] += 1 From bb721ad53b59ee7adc79ecc49c5f1bdb6e856b30 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:33:53 +0000 Subject: [PATCH 72/90] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#verify=5Ffix=5Fcommit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_fix_commit と verify_apply_item のループが同じ順序で書いていた exists 確認・トレーラー・範囲・test_status の 4 手順を _verify_commit_basics へ寄せた。適用と修正で基準が食い違わなくなる。 実体が無いときの理由文は範囲の呼び方が違うため引数で受け取り、 出力は両者とも従来のままにしている。振る舞いは変えていない。 Item-Id: R3-005 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 50e7285..72b4023 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -514,16 +514,21 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_commit_basics( + commit: dict[str, Any], + scope: Optional[Iterable[str]], + missing_reason: str, ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """コミット 1 件が手順を満たしているかを検証する。問題があれば理由を返す。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 適用(`verify_apply_item`)と修正(`verify_fix_commit`)で**同じ基準**を使う。 + 片方だけ直されると基準が食い違い、緩い側から手順を外れた変更が入る。 + + 実体が無いときの理由文だけは呼び出し側から渡す。範囲の呼び方が適用 + (base..head)と修正(修正ラウンドの範囲)で違うためである。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return missing_reason problem = verify_commit_trailers(commit) if problem: return problem @@ -538,6 +543,21 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません", + ) + + def diff_budget_factor(technique: Optional[str]) -> int: """その手法に許す差分予算の倍率。 @@ -562,22 +582,14 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)", + ) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( From aed8d44665a654cce3401ad77d1108059dfd70f9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 73/90] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdupli?= =?UTF-8?q?cation=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactorin?= =?UTF-8?q?g/scripts/refactor.py#verify=5Ffix=5Fcommit"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bb721ad53b59ee7adc79ecc49c5f1bdb6e856b30. --- .../cross-refactoring/scripts/refactor.py | 52 +++++++------------ 1 file changed, 20 insertions(+), 32 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 72b4023..50e7285 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -514,21 +514,16 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def _verify_commit_basics( - commit: dict[str, Any], - scope: Optional[Iterable[str]], - missing_reason: str, +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None ) -> Optional[str]: - """コミット 1 件が手順を満たしているかを検証する。問題があれば理由を返す。 - - 適用(`verify_apply_item`)と修正(`verify_fix_commit`)で**同じ基準**を使う。 - 片方だけ直されると基準が食い違い、緩い側から手順を外れた変更が入る。 + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - 実体が無いときの理由文だけは呼び出し側から渡す。範囲の呼び方が適用 - (base..head)と修正(修正ラウンドの範囲)で違うためである。 + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 """ if not commit.get("exists", True): - return missing_reason + return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" problem = verify_commit_trailers(commit) if problem: return problem @@ -543,21 +538,6 @@ def _verify_commit_basics( return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None -) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 - - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 - """ - return _verify_commit_basics( - commit, - scope, - f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません", - ) - - def diff_budget_factor(technique: Optional[str]) -> int: """その手法に許す差分予算の倍率。 @@ -582,14 +562,22 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - problem = _verify_commit_basics( - commit, - scope, - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)", - ) + if not commit.get("exists", True): + return ( + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)" + ) + 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": + return ( + f"コミット {commit.get('sha', '?')} でテストが成功していません " + f"({commit.get('test_status')})" + ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( From 15e58b4e152aa30073a63e437e06e59fd4fe5ea8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 74/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 887471c0fd5e29484c9d7f1bc984c059632eed8b. --- .../skills/cross-refactoring/scripts/refactor.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 50e7285..92c4d9a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2211,15 +2211,6 @@ def _verify_fix_commits( return problems, accepted -def _mark_resolved_fix_findings(entry: dict[str, Any], resolved: set[str]) -> None: - """GitHub 側で解決済みになった thread に対応する指摘へ、解決の印を付ける。""" - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True - - def _record_accepted_fix_commits( state: dict[str, Any], accepted: list[tuple[str, str]] ) -> None: @@ -2328,7 +2319,11 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: else: _record_accepted_fix_commits(state, accepted) - _mark_resolved_fix_findings(entry, resolved) + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True merged_keys.append(merge_key) entry["fix_rounds"] += 1 From 04c270f99520ef6f391f81e051eff9409df5f585 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 75/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 8864dc3aff2b77314ba088182861ba007501365b. --- .../cross-refactoring/scripts/refactor.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 92c4d9a..b4114d6 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2211,19 +2211,6 @@ def _verify_fix_commits( return problems, accepted -def _record_accepted_fix_commits( - state: dict[str, Any], accepted: list[tuple[str, str]] -) -> None: - """検証を通った修正コミットを、対応する改善項目へ紐づける。 - - 見送り済みなどで項目が見つからないコミットは、紐づけ先が無いので飛ばす。 - """ - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) - - def _revert_invalid_fix_round( path: pathlib.Path, state: dict[str, Any], @@ -2317,7 +2304,10 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) else: - _record_accepted_fix_commits(state, accepted) + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) for review in entry["reviews"]: for finding in review["findings"]: From 5c853eea708f58479393a465810c8874e51d8224 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 76/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Fmerge=5Ffix"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit af392b37175c0279e4974148976767c97a41bbf9. --- .../cross-refactoring/scripts/refactor.py | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index b4114d6..6cfd940 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2211,41 +2211,6 @@ def _verify_fix_commits( return problems, accepted -def _revert_invalid_fix_round( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], -) -> set[str]: - """検証を通らない修正ラウンドの範囲を取り消し、採用する解決スレッドを返す。 - - 取り消した以上、解決の申告も採らないので**常に空集合を返す**。 - """ - work = state["worktrees"]["work"] - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - 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}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - info("⚠ 修正を取り消したため、解決の申告は採用しません") - return set() - - def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2302,7 +2267,28 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) if unassigned or problems: - resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + 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}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + info("⚠ 修正を取り消したため、解決の申告は採用しません") + resolved = set() else: for item_id, sha in accepted: item = _find_item(state, item_id, required=False) From 91f3559a79554f2e8f912276b6e2ca51aaac28b1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 77/90] =?UTF-8?q?Revert=20"Refactor:=20flatten=5Fcondition?= =?UTF-8?q?al=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/sc?= =?UTF-8?q?ripts/refactor.py#=5Fhandle=5Finvalid=5Freview=5Fverdict"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 759a1354b2ea357a968b2103068facdfa92f2a89. --- .../cross-refactoring/scripts/refactor.py | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 6cfd940..9678438 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1870,25 +1870,6 @@ def _remember(exit_code: int) -> None: ) -def _blocked_reviewers_without_actionable_findings( - reviewers: list[str], - reviews: dict[str, dict[str, Any]], - unposted: list[str], -) -> list[str]: - """実装担当が読める指摘を残せなかったレビュー担当を挙げる。 - - **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは - レビュー担当のプロセスが仕事をしなかったということで、実装担当が - 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された - 実装担当が空回りし、承認済みの項目まで見送りへ進む。 - - **投稿できなかった担当も同じ扱いにする。** 判定は残っていても - Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 - """ - missing = [name for name in reviewers if name not in reviews] - return missing + [name for name in unposted if name not in missing] - - def _handle_invalid_review_verdict( path: pathlib.Path, state: dict[str, Any], @@ -1912,7 +1893,14 @@ def _handle_invalid_review_verdict( statefile.save(path, state) info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") sys.exit(3) - blocked = _blocked_reviewers_without_actionable_findings(reviewers, reviews, unposted) + # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは + # レビュー担当のプロセスが仕事をしなかったということで、実装担当が + # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された + # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 + missing = [name for name in reviewers if name not in reviews] + # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても + # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 + blocked = missing + [name for name in unposted if name not in missing] if blocked: remember(ABORT) statefile.save(path, state) From 5c65da14b5934a21c1b1063631f7184c741bbbb9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 78/90] =?UTF-8?q?Revert=20"Refactor:=20flatten=5Fcondition?= =?UTF-8?q?al=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/sc?= =?UTF-8?q?ripts/refactor.py#=5Fhandle=5Finvalid=5Freview=5Fverdict"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 369bffa4882cba901f6f7e07542f9e144b58feeb. --- .../cross-refactoring/scripts/refactor.py | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 9678438..2e3a370 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1888,50 +1888,50 @@ def _handle_invalid_review_verdict( for p in problems: info(f"❌ {p}") entry["invalid_reviews"] = entry.get("invalid_reviews", 0) + 1 - if entry["invalid_reviews"] <= MAX_INVALID_REVIEWS: - remember(3) - statefile.save(path, state) - info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") - sys.exit(3) - # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは - # レビュー担当のプロセスが仕事をしなかったということで、実装担当が - # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された - # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 - missing = [name for name in reviewers if name not in reviews] - # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても - # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 - blocked = missing + [name for name in unposted if name not in missing] - if blocked: - remember(ABORT) + if entry["invalid_reviews"] > MAX_INVALID_REVIEWS: + # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは + # レビュー担当のプロセスが仕事をしなかったということで、実装担当が + # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された + # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 + missing = [name for name in reviewers if name not in reviews] + # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても + # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 + blocked = missing + [name for name in unposted if name not in missing] + if blocked: + remember(ABORT) + statefile.save(path, state) + die( + f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" + "(結果ファイルの欠落、または投稿の失敗)。" + "実装担当への指摘ではないため、進行を中断します。" + "原因を直して同じコマンド列を叩き直せば再開できます" + ) + # 差し戻しを無限に繰り返さない。形式を満たせないレビューが続く以上、 + # このラウンドの成果は検証されていないものとして扱い、変更要求へ落とす。 + # 紐づけ先が決まらないので、取り消しはラウンド全件が対象になる。 + record["findings"].append({ + "reviewer": "cross-refactoring", + "item_id": None, + "thread_id": None, + "summary": ( + f"レビュー結果の形式が {MAX_INVALID_REVIEWS + 1} 回続けて不正だった: " + + " / ".join(problems) + ), + "resolved": False, + }) + # **この出口も修正フェーズの起点を記録する。** 記録せずに変更要求を + # 返すと `merge-fix` が範囲を確定できずに弾かれ、`fix_rounds` が + # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 + # 上限へ永久に到達せず修正と再レビューを往復し続ける。 + _prepare_fix_phase(state, entry) + remember(2) statefile.save(path, state) - die( - f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" - "(結果ファイルの欠落、または投稿の失敗)。" - "実装担当への指摘ではないため、進行を中断します。" - "原因を直して同じコマンド列を叩き直せば再開できます" - ) - # 差し戻しを無限に繰り返さない。形式を満たせないレビューが続く以上、 - # このラウンドの成果は検証されていないものとして扱い、変更要求へ落とす。 - # 紐づけ先が決まらないので、取り消しはラウンド全件が対象になる。 - record["findings"].append({ - "reviewer": "cross-refactoring", - "item_id": None, - "thread_id": None, - "summary": ( - f"レビュー結果の形式が {MAX_INVALID_REVIEWS + 1} 回続けて不正だった: " - + " / ".join(problems) - ), - "resolved": False, - }) - # **この出口も修正フェーズの起点を記録する。** 記録せずに変更要求を - # 返すと `merge-fix` が範囲を確定できずに弾かれ、`fix_rounds` が - # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 - # 上限へ永久に到達せず修正と再レビューを往復し続ける。 - _prepare_fix_phase(state, entry) - remember(2) + info("差し戻しの上限に達したため、変更要求として扱います") + sys.exit(2) + remember(3) statefile.save(path, state) - info("差し戻しの上限に達したため、変更要求として扱います") - sys.exit(2) + info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") + sys.exit(3) def _handle_review_verdict( From 736721d0e758844b31d0d05cffb70fe6d0412350 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 79/90] =?UTF-8?q?Revert=20"Refactor:=20flatten=5Fcondition?= =?UTF-8?q?al=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/sc?= =?UTF-8?q?ripts/refactor.py#=5Fhandle=5Freview=5Fverdict"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 299043c242bb15c8f0c6bae3a442217779003dd0. --- .../cross-refactoring/scripts/refactor.py | 114 ++++++++---------- 1 file changed, 47 insertions(+), 67 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2e3a370..2bdff3e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -1870,70 +1870,6 @@ def _remember(exit_code: int) -> None: ) -def _handle_invalid_review_verdict( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - reviewers: list[str], - reviews: dict[str, dict[str, Any]], - unposted: list[str], - problems: list[str], - record: dict[str, Any], - remember: Callable[[int], None], -) -> None: - """形式を満たさないレビュー結果を、差し戻し・中断・変更要求のどれかへ振り分ける。 - - この関数から通常の復帰はしない。必ず `sys.exit` か `die` で抜ける。 - """ - for p in problems: - info(f"❌ {p}") - entry["invalid_reviews"] = entry.get("invalid_reviews", 0) + 1 - if entry["invalid_reviews"] > MAX_INVALID_REVIEWS: - # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは - # レビュー担当のプロセスが仕事をしなかったということで、実装担当が - # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された - # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 - missing = [name for name in reviewers if name not in reviews] - # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても - # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 - blocked = missing + [name for name in unposted if name not in missing] - if blocked: - remember(ABORT) - statefile.save(path, state) - die( - f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" - "(結果ファイルの欠落、または投稿の失敗)。" - "実装担当への指摘ではないため、進行を中断します。" - "原因を直して同じコマンド列を叩き直せば再開できます" - ) - # 差し戻しを無限に繰り返さない。形式を満たせないレビューが続く以上、 - # このラウンドの成果は検証されていないものとして扱い、変更要求へ落とす。 - # 紐づけ先が決まらないので、取り消しはラウンド全件が対象になる。 - record["findings"].append({ - "reviewer": "cross-refactoring", - "item_id": None, - "thread_id": None, - "summary": ( - f"レビュー結果の形式が {MAX_INVALID_REVIEWS + 1} 回続けて不正だった: " - + " / ".join(problems) - ), - "resolved": False, - }) - # **この出口も修正フェーズの起点を記録する。** 記録せずに変更要求を - # 返すと `merge-fix` が範囲を確定できずに弾かれ、`fix_rounds` が - # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 - # 上限へ永久に到達せず修正と再レビューを往復し続ける。 - _prepare_fix_phase(state, entry) - remember(2) - statefile.save(path, state) - info("差し戻しの上限に達したため、変更要求として扱います") - sys.exit(2) - remember(3) - statefile.save(path, state) - info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") - sys.exit(3) - - def _handle_review_verdict( path: pathlib.Path, state: dict[str, Any], @@ -1947,9 +1883,53 @@ def _handle_review_verdict( remember: Callable[[int], None], ) -> None: if verdict == "invalid": - _handle_invalid_review_verdict( - path, state, entry, reviewers, reviews, unposted, problems, record, remember, - ) + for p in problems: + info(f"❌ {p}") + entry["invalid_reviews"] = entry.get("invalid_reviews", 0) + 1 + if entry["invalid_reviews"] > MAX_INVALID_REVIEWS: + # **結果が無いことと、形が違うことを分ける。** 結果を残さなかったのは + # レビュー担当のプロセスが仕事をしなかったということで、実装担当が + # 直せる指摘ではない。変更要求へ落とすと、直しようのない指摘を渡された + # 実装担当が空回りし、承認済みの項目まで見送りへ進む。 + missing = [name for name in reviewers if name not in reviews] + # **投稿できなかった担当も同じ扱いにする。** 判定は残っていても + # Pull Request に指摘が無い以上、実装担当が読めるものは存在しない。 + blocked = missing + [name for name in unposted if name not in missing] + if blocked: + remember(ABORT) + statefile.save(path, state) + die( + f"レビュー担当 {' / '.join(blocked)} が結果を残せませんでした" + "(結果ファイルの欠落、または投稿の失敗)。" + "実装担当への指摘ではないため、進行を中断します。" + "原因を直して同じコマンド列を叩き直せば再開できます" + ) + # 差し戻しを無限に繰り返さない。形式を満たせないレビューが続く以上、 + # このラウンドの成果は検証されていないものとして扱い、変更要求へ落とす。 + # 紐づけ先が決まらないので、取り消しはラウンド全件が対象になる。 + record["findings"].append({ + "reviewer": "cross-refactoring", + "item_id": None, + "thread_id": None, + "summary": ( + f"レビュー結果の形式が {MAX_INVALID_REVIEWS + 1} 回続けて不正だった: " + + " / ".join(problems) + ), + "resolved": False, + }) + # **この出口も修正フェーズの起点を記録する。** 記録せずに変更要求を + # 返すと `merge-fix` が範囲を確定できずに弾かれ、`fix_rounds` が + # 進まない。`should-abandon` は `fix_rounds` で見送りを決めるため、 + # 上限へ永久に到達せず修正と再レビューを往復し続ける。 + _prepare_fix_phase(state, entry) + remember(2) + statefile.save(path, state) + info("差し戻しの上限に達したため、変更要求として扱います") + sys.exit(2) + remember(3) + statefile.save(path, state) + info("レビュー結果を差し戻します。指摘には必ず改善項目 ID を付けてください") + sys.exit(3) if verdict == "approved": for item_id in entry["apply"]["applied"]: _find_item(state, item_id)["status"] = "done" From aed535e65868d768ede7d372cfc375dfa5bab48e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 80/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#=5Fsync=5Fgenerated"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit beed674c4e935bea6bf4caf23c0e160e439a27b1. --- .../cross-refactoring/scripts/refactor.py | 38 ++++++++----------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2bdff3e..ea5e63a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -3165,28 +3165,6 @@ def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: ) -def _commit_sync_changes(work: str, command: str, produced: list[str]) -> None: - """同期が作った差分を進行側のコミットとして積む。差分が無ければ何もしない。 - - このコミットはどの改善項目にも属さない。取り消しでは積み直されないが、 - 次の push で作り直されるので失われても問題にならない。 - """ - if not produced: - return - # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で - # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で - # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は - # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを - # 確認済みだからである。 - try: - _sh(["git", "add", "--", *produced], cwd=work) - _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) - except SystemExit: - _discard_worktree_changes(work) - raise - info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") - - def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3209,7 +3187,21 @@ def _sync_generated(state: dict[str, Any]) -> None: # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) _run_sync_command(state, work, command) - _commit_sync_changes(work, command, _dirty_paths(state, work)) + produced = _dirty_paths(state, work) + if not produced: + return + # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で + # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で + # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は + # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを + # 確認済みだからである。 + try: + _sh(["git", "add", "--", *produced], cwd=work) + _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) + except SystemExit: + _discard_worktree_changes(work) + raise + info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") def _push_head(state: dict[str, Any]) -> None: From 5470eb78a8c2b84f892ba9fa05a823776b0db806 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 81/90] =?UTF-8?q?Revert=20"Refactor:=20split=5Finto=5Fpipe?= =?UTF-8?q?line=20=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/?= =?UTF-8?q?scripts/refactor.py#=5Fsync=5Fgenerated"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6eac88268e30808f1d126fcfb2be2516afd6827c. --- .../cross-refactoring/scripts/refactor.py | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ea5e63a..35727a9 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -3142,29 +3142,6 @@ def _require_clean_worktree(state: dict[str, Any], work: str) -> None: ) -def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: - """同期コマンドを実行する。失敗したら差分を捨てて中断する。 - - **黙って push しない。** 同期できない状態を公開すると、利用者のリポジトリの - 検査を壊したまま進むことになる。 - """ - code, timed_out = _run_with_timeout( - command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) - ) - if not (timed_out or code != 0): - return - # **途中まで書き換えた差分を残さない。** 残すと次の実行は - # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が - # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 - # ここにある変更は全て同期が作ったものだと分かる。 - _discard_worktree_changes(work) - die( - f"生成物の同期に失敗しました({command}): " - + ("打ち切りました" if timed_out else f"終了コード {code}") - + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" - ) - - def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3186,7 +3163,20 @@ def _sync_generated(state: dict[str, Any]) -> None: # **同期の前に作業ツリーが綺麗であることを求める。** 汚れたまま同期すると、 # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) - _run_sync_command(state, work, command) + code, timed_out = _run_with_timeout( + command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + ) + if timed_out or code != 0: + # **途中まで書き換えた差分を残さない。** 残すと次の実行は + # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が + # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 + # ここにある変更は全て同期が作ったものだと分かる。 + _discard_worktree_changes(work) + die( + f"生成物の同期に失敗しました({command}): " + + ("打ち切りました" if timed_out else f"終了コード {code}") + + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" + ) produced = _dirty_paths(state, work) if not produced: return From 0902327a1e9e0d6e36714a96121d6c852ed9129d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:08 +0000 Subject: [PATCH 82/90] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#cmd=5Finit"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 107be5e6b20bd6601efc9a12682ea45b45f1d4ac. --- .../cross-refactoring/scripts/refactor.py | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 35727a9..5f23eb2 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -895,28 +895,6 @@ def _warn_unmeasurable_models( ) -def _fetch_pr_context(pr: int) -> tuple[str, str, bool, str]: - """GitHub から Pull Request のメタデータを取り、自分の Pull Request かを判定する。 - - 返すのは `(base_branch, head_branch, is_own_pr, author)`。 - """ - # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は - # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの - # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 - viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) - author = _sh( - ["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"] - ) - is_own_pr = bool(viewer) and viewer == author - head_branch = _sh( - ["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"] - ) - base_branch = _sh( - ["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"] - ) - return base_branch, head_branch, is_own_pr, author - - def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -945,9 +923,22 @@ def cmd_init(args: argparse.Namespace) -> None: auth = check_auth(sorted(set(runtimes) | set(impl_capable))) repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) - base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) + # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は + # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの + # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 + viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) + author = _sh( + ["gh", "pr", "view", str(args.pr), "--json", "author", "--jq", ".author.login"] + ) + is_own_pr = bool(viewer) and viewer == author if is_own_pr: info(f"⚠ 自分の Pull Request です(作成者 {author})— 投稿は COMMENT へ倒します") + head_branch = _sh( + ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] + ) + base_branch = _sh( + ["gh", "pr", "view", str(args.pr), "--json", "baseRefName", "--jq", ".baseRefName"] + ) root = ( pathlib.Path(args.worktree_root).resolve() if args.worktree_root From b37a44715c8e90f07bb5d89107f1c804b01f71e2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:24:57 +0000 Subject: [PATCH 83/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Finit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_init に直書きしていた GitHub からの Pull Request メタデータ取得 (viewer / author / headRefName / baseRefName の 4 回の gh 呼び出しと is_own_pr の判定)を _fetch_pr_context として抽出した。cmd_init は 戻り値を受け取るだけになり、他の段階(_ensure_work_worktree など)と 同じ粒度で段階の切れ目が読めるようになる。振る舞いは変えていない。 Item-Id: R3-001 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 37 ++++++++++++------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 5f23eb2..35727a9 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -895,6 +895,28 @@ def _warn_unmeasurable_models( ) +def _fetch_pr_context(pr: int) -> tuple[str, str, bool, str]: + """GitHub から Pull Request のメタデータを取り、自分の Pull Request かを判定する。 + + 返すのは `(base_branch, head_branch, is_own_pr, author)`。 + """ + # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は + # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの + # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 + viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) + author = _sh( + ["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"] + ) + is_own_pr = bool(viewer) and viewer == author + head_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"] + ) + base_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"] + ) + return base_branch, head_branch, is_own_pr, author + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -923,22 +945,9 @@ def cmd_init(args: argparse.Namespace) -> None: auth = check_auth(sorted(set(runtimes) | set(impl_capable))) repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) - # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は - # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの - # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 - viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) - author = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "author", "--jq", ".author.login"] - ) - is_own_pr = bool(viewer) and viewer == author + base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) if is_own_pr: info(f"⚠ 自分の Pull Request です(作成者 {author})— 投稿は COMMENT へ倒します") - head_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] - ) - base_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "baseRefName", "--jq", ".baseRefName"] - ) root = ( pathlib.Path(args.worktree_root).resolve() if args.worktree_root From 7f527139403d3786a2726ee15de96e98022670af Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:25:57 +0000 Subject: [PATCH 84/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#=5Fsync=5Fgenerated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同期コマンドの実行と失敗時の破棄・中断を _run_sync_command として 切り出した。_sync_generated 側は 1 行の呼び出しになり、パイプラインの 段が名前で読めるようになる。振る舞いは変えていない。 Item-Id: R3-002 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 35727a9..ea5e63a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -3142,6 +3142,29 @@ def _require_clean_worktree(state: dict[str, Any], work: str) -> None: ) +def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: + """同期コマンドを実行する。失敗したら差分を捨てて中断する。 + + **黙って push しない。** 同期できない状態を公開すると、利用者のリポジトリの + 検査を壊したまま進むことになる。 + """ + code, timed_out = _run_with_timeout( + command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + ) + if not (timed_out or code != 0): + return + # **途中まで書き換えた差分を残さない。** 残すと次の実行は + # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が + # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 + # ここにある変更は全て同期が作ったものだと分かる。 + _discard_worktree_changes(work) + die( + f"生成物の同期に失敗しました({command}): " + + ("打ち切りました" if timed_out else f"終了コード {code}") + + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" + ) + + def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3163,20 +3186,7 @@ def _sync_generated(state: dict[str, Any]) -> None: # **同期の前に作業ツリーが綺麗であることを求める。** 汚れたまま同期すると、 # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) - code, timed_out = _run_with_timeout( - command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) - ) - if timed_out or code != 0: - # **途中まで書き換えた差分を残さない。** 残すと次の実行は - # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が - # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 - # ここにある変更は全て同期が作ったものだと分かる。 - _discard_worktree_changes(work) - die( - f"生成物の同期に失敗しました({command}): " - + ("打ち切りました" if timed_out else f"終了コード {code}") - + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" - ) + _run_sync_command(state, work, command) produced = _dirty_paths(state, work) if not produced: return From f28790aa425c3bf96c9a35d1beb0b8c6bdf3d71a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:26:46 +0000 Subject: [PATCH 85/90] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#=5Fsync=5Fgenerated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成差分の git add / commit と失敗時の破棄を _commit_sync_changes として 切り出した。_sync_generated は command 判定 → 清浄性確認 → 実行 → 差分検出 → コミット の呼び出し列だけになる。振る舞いは変えていない。 Item-Id: R3-002 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ea5e63a..2bdff3e 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -3165,6 +3165,28 @@ def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: ) +def _commit_sync_changes(work: str, command: str, produced: list[str]) -> None: + """同期が作った差分を進行側のコミットとして積む。差分が無ければ何もしない。 + + このコミットはどの改善項目にも属さない。取り消しでは積み直されないが、 + 次の push で作り直されるので失われても問題にならない。 + """ + if not produced: + return + # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で + # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で + # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は + # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを + # 確認済みだからである。 + try: + _sh(["git", "add", "--", *produced], cwd=work) + _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) + except SystemExit: + _discard_worktree_changes(work) + raise + info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + + def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3187,21 +3209,7 @@ def _sync_generated(state: dict[str, Any]) -> None: # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) _run_sync_command(state, work, command) - produced = _dirty_paths(state, work) - if not produced: - return - # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で - # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で - # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は - # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを - # 確認済みだからである。 - try: - _sh(["git", "add", "--", *produced], cwd=work) - _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) - except SystemExit: - _discard_worktree_changes(work) - raise - info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + _commit_sync_changes(work, command, _dirty_paths(state, work)) def _push_head(state: dict[str, Any]) -> None: From e7ebc1bc702dd745aea5f56f4e4b1687573e099e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:31:12 +0000 Subject: [PATCH 86/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証を通らない修正ラウンドの取り消しを _revert_invalid_fix_round へ 抽出した。pending_push の印・取り消し・起点の更新・解決の申告を 採らない判断が 1 か所にまとまる。振る舞いは変えていない。 Item-Id: R3-004 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 2bdff3e..f7c892c 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2179,6 +2179,41 @@ def _verify_fix_commits( return problems, accepted +def _revert_invalid_fix_round( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> set[str]: + """検証を通らない修正ラウンドの範囲を取り消し、採用する解決スレッドを返す。 + + 取り消した以上、解決の申告も採らないので**常に空集合を返す**。 + """ + work = state["worktrees"]["work"] + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + 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}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return set() + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2235,28 +2270,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - 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}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() + resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) else: for item_id, sha in accepted: item = _find_item(state, item_id, required=False) From c5e10aee214328bb7780c97be4f99df8e4529b0a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:31:54 +0000 Subject: [PATCH 87/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証を通った修正コミットを改善項目へ紐づけるループを _record_accepted_fix_commits へ抽出した。振る舞いは変えていない。 Item-Id: R3-004 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index f7c892c..ac6856a 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2179,6 +2179,19 @@ def _verify_fix_commits( return problems, accepted +def _record_accepted_fix_commits( + state: dict[str, Any], accepted: list[tuple[str, str]] +) -> None: + """検証を通った修正コミットを、対応する改善項目へ紐づける。 + + 見送り済みなどで項目が見つからないコミットは、紐づけ先が無いので飛ばす。 + """ + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + + def _revert_invalid_fix_round( path: pathlib.Path, state: dict[str, Any], @@ -2272,10 +2285,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: if unassigned or problems: resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + _record_accepted_fix_commits(state, accepted) for review in entry["reviews"]: for finding in review["findings"]: From 5a4accab55903c458d4e19b3fdaa805918a7454c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:32:34 +0000 Subject: [PATCH 88/90] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf-shared/skills/cross-refactoring/scripts/refactor?= =?UTF-8?q?.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 解決済み thread を指摘へ反映する二重ループを _mark_resolved_fix_findings へ抽出した。cmd_merge_fix は読み込み → 検証 → 記録 → push の流れだけに なる。振る舞いは変えていない。 Item-Id: R3-004 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../skills/cross-refactoring/scripts/refactor.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index ac6856a..7d54394 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -2179,6 +2179,15 @@ def _verify_fix_commits( return problems, accepted +def _mark_resolved_fix_findings(entry: dict[str, Any], resolved: set[str]) -> None: + """GitHub 側で解決済みになった thread に対応する指摘へ、解決の印を付ける。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + def _record_accepted_fix_commits( state: dict[str, Any], accepted: list[tuple[str, str]] ) -> None: @@ -2287,11 +2296,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: else: _record_accepted_fix_commits(state, accepted) - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True + _mark_resolved_fix_findings(entry, resolved) merged_keys.append(merge_key) entry["fix_rounds"] += 1 From 75f20b3fb842d8895f5f58c244bf9f5b4a7f4648 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:33:53 +0000 Subject: [PATCH 89/90] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf-shared/skills/cross-refactoring/scripts?= =?UTF-8?q?/refactor.py#verify=5Ffix=5Fcommit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_fix_commit と verify_apply_item のループが同じ順序で書いていた exists 確認・トレーラー・範囲・test_status の 4 手順を _verify_commit_basics へ寄せた。適用と修正で基準が食い違わなくなる。 実体が無いときの理由文は範囲の呼び方が違うため引数で受け取り、 出力は両者とも従来のままにしている。振る舞いは変えていない。 Item-Id: R3-005 Round: 3 Impl-Runtime: claude Impl-Model: default --- .../cross-refactoring/scripts/refactor.py | 52 ++++++++++++------- 1 file changed, 32 insertions(+), 20 deletions(-) diff --git a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py index 7d54394..685de46 100755 --- a/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-shared/skills/cross-refactoring/scripts/refactor.py @@ -514,16 +514,21 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_commit_basics( + commit: dict[str, Any], + scope: Optional[Iterable[str]], + missing_reason: str, ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """コミット 1 件が手順を満たしているかを検証する。問題があれば理由を返す。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 適用(`verify_apply_item`)と修正(`verify_fix_commit`)で**同じ基準**を使う。 + 片方だけ直されると基準が食い違い、緩い側から手順を外れた変更が入る。 + + 実体が無いときの理由文だけは呼び出し側から渡す。範囲の呼び方が適用 + (base..head)と修正(修正ラウンドの範囲)で違うためである。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return missing_reason problem = verify_commit_trailers(commit) if problem: return problem @@ -538,6 +543,21 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません", + ) + + def diff_budget_factor(technique: Optional[str]) -> int: """その手法に許す差分予算の倍率。 @@ -562,22 +582,14 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)", + ) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( From fe9e396c4719eae8a5a754924c0f0f78a27de213 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 22 Aug 2026 16:39:09 +0000 Subject: [PATCH 90/90] =?UTF-8?q?Chore:=20=E7=94=9F=E6=88=90=E7=89=A9?= =?UTF-8?q?=E3=82=92=E5=90=8C=E6=9C=9F=E3=81=99=E3=82=8B=EF=BC=88cross-ref?= =?UTF-8?q?actoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装担当は対象範囲だけを変更するため、生成物が同期されない。 同期を検査する pre-push を持つリポジトリでも push できるよう、 公開の直前に進行側がまとめて生成する。 --- .../cross-refactoring/scripts/refactor.py | 256 +++++++++++------- .../cross-refactoring/scripts/refactor.py | 256 +++++++++++------- .../cross-refactoring/scripts/refactor.py | 256 +++++++++++------- 3 files changed, 486 insertions(+), 282 deletions(-) diff --git a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py index 5f23eb2..685de46 100755 --- a/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-claude/skills/cross-refactoring/scripts/refactor.py @@ -514,16 +514,21 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_commit_basics( + commit: dict[str, Any], + scope: Optional[Iterable[str]], + missing_reason: str, ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """コミット 1 件が手順を満たしているかを検証する。問題があれば理由を返す。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 適用(`verify_apply_item`)と修正(`verify_fix_commit`)で**同じ基準**を使う。 + 片方だけ直されると基準が食い違い、緩い側から手順を外れた変更が入る。 + + 実体が無いときの理由文だけは呼び出し側から渡す。範囲の呼び方が適用 + (base..head)と修正(修正ラウンドの範囲)で違うためである。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return missing_reason problem = verify_commit_trailers(commit) if problem: return problem @@ -538,6 +543,21 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません", + ) + + def diff_budget_factor(technique: Optional[str]) -> int: """その手法に許す差分予算の倍率。 @@ -562,22 +582,14 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)", + ) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -895,6 +907,28 @@ def _warn_unmeasurable_models( ) +def _fetch_pr_context(pr: int) -> tuple[str, str, bool, str]: + """GitHub から Pull Request のメタデータを取り、自分の Pull Request かを判定する。 + + 返すのは `(base_branch, head_branch, is_own_pr, author)`。 + """ + # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は + # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの + # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 + viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) + author = _sh( + ["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"] + ) + is_own_pr = bool(viewer) and viewer == author + head_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"] + ) + base_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"] + ) + return base_branch, head_branch, is_own_pr, author + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -923,22 +957,9 @@ def cmd_init(args: argparse.Namespace) -> None: auth = check_auth(sorted(set(runtimes) | set(impl_capable))) repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) - # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は - # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの - # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 - viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) - author = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "author", "--jq", ".author.login"] - ) - is_own_pr = bool(viewer) and viewer == author + base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) if is_own_pr: info(f"⚠ 自分の Pull Request です(作成者 {author})— 投稿は COMMENT へ倒します") - head_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] - ) - base_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "baseRefName", "--jq", ".baseRefName"] - ) root = ( pathlib.Path(args.worktree_root).resolve() if args.worktree_root @@ -2170,6 +2191,63 @@ def _verify_fix_commits( return problems, accepted +def _mark_resolved_fix_findings(entry: dict[str, Any], resolved: set[str]) -> None: + """GitHub 側で解決済みになった thread に対応する指摘へ、解決の印を付ける。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + +def _record_accepted_fix_commits( + state: dict[str, Any], accepted: list[tuple[str, str]] +) -> None: + """検証を通った修正コミットを、対応する改善項目へ紐づける。 + + 見送り済みなどで項目が見つからないコミットは、紐づけ先が無いので飛ばす。 + """ + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + + +def _revert_invalid_fix_round( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> set[str]: + """検証を通らない修正ラウンドの範囲を取り消し、採用する解決スレッドを返す。 + + 取り消した以上、解決の申告も採らないので**常に空集合を返す**。 + """ + work = state["worktrees"]["work"] + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + 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}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return set() + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2226,39 +2304,11 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - 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}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() + resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + _record_accepted_fix_commits(state, accepted) - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True + _mark_resolved_fix_findings(entry, resolved) merged_keys.append(merge_key) entry["fix_rounds"] += 1 @@ -3133,6 +3183,51 @@ def _require_clean_worktree(state: dict[str, Any], work: str) -> None: ) +def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: + """同期コマンドを実行する。失敗したら差分を捨てて中断する。 + + **黙って push しない。** 同期できない状態を公開すると、利用者のリポジトリの + 検査を壊したまま進むことになる。 + """ + code, timed_out = _run_with_timeout( + command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + ) + if not (timed_out or code != 0): + return + # **途中まで書き換えた差分を残さない。** 残すと次の実行は + # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が + # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 + # ここにある変更は全て同期が作ったものだと分かる。 + _discard_worktree_changes(work) + die( + f"生成物の同期に失敗しました({command}): " + + ("打ち切りました" if timed_out else f"終了コード {code}") + + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" + ) + + +def _commit_sync_changes(work: str, command: str, produced: list[str]) -> None: + """同期が作った差分を進行側のコミットとして積む。差分が無ければ何もしない。 + + このコミットはどの改善項目にも属さない。取り消しでは積み直されないが、 + 次の push で作り直されるので失われても問題にならない。 + """ + if not produced: + return + # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で + # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で + # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は + # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを + # 確認済みだからである。 + try: + _sh(["git", "add", "--", *produced], cwd=work) + _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) + except SystemExit: + _discard_worktree_changes(work) + raise + info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + + def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3154,35 +3249,8 @@ def _sync_generated(state: dict[str, Any]) -> None: # **同期の前に作業ツリーが綺麗であることを求める。** 汚れたまま同期すると、 # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) - code, timed_out = _run_with_timeout( - command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) - ) - if timed_out or code != 0: - # **途中まで書き換えた差分を残さない。** 残すと次の実行は - # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が - # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 - # ここにある変更は全て同期が作ったものだと分かる。 - _discard_worktree_changes(work) - die( - f"生成物の同期に失敗しました({command}): " - + ("打ち切りました" if timed_out else f"終了コード {code}") - + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" - ) - produced = _dirty_paths(state, work) - if not produced: - return - # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で - # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で - # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は - # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを - # 確認済みだからである。 - try: - _sh(["git", "add", "--", *produced], cwd=work) - _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) - except SystemExit: - _discard_worktree_changes(work) - raise - info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + _run_sync_command(state, work, command) + _commit_sync_changes(work, command, _dirty_paths(state, work)) def _push_head(state: dict[str, Any]) -> None: diff --git a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py index 5f23eb2..685de46 100755 --- a/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-codex/skills/cross-refactoring/scripts/refactor.py @@ -514,16 +514,21 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_commit_basics( + commit: dict[str, Any], + scope: Optional[Iterable[str]], + missing_reason: str, ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """コミット 1 件が手順を満たしているかを検証する。問題があれば理由を返す。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 適用(`verify_apply_item`)と修正(`verify_fix_commit`)で**同じ基準**を使う。 + 片方だけ直されると基準が食い違い、緩い側から手順を外れた変更が入る。 + + 実体が無いときの理由文だけは呼び出し側から渡す。範囲の呼び方が適用 + (base..head)と修正(修正ラウンドの範囲)で違うためである。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return missing_reason problem = verify_commit_trailers(commit) if problem: return problem @@ -538,6 +543,21 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません", + ) + + def diff_budget_factor(technique: Optional[str]) -> int: """その手法に許す差分予算の倍率。 @@ -562,22 +582,14 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)", + ) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -895,6 +907,28 @@ def _warn_unmeasurable_models( ) +def _fetch_pr_context(pr: int) -> tuple[str, str, bool, str]: + """GitHub から Pull Request のメタデータを取り、自分の Pull Request かを判定する。 + + 返すのは `(base_branch, head_branch, is_own_pr, author)`。 + """ + # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は + # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの + # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 + viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) + author = _sh( + ["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"] + ) + is_own_pr = bool(viewer) and viewer == author + head_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"] + ) + base_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"] + ) + return base_branch, head_branch, is_own_pr, author + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -923,22 +957,9 @@ def cmd_init(args: argparse.Namespace) -> None: auth = check_auth(sorted(set(runtimes) | set(impl_capable))) repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) - # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は - # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの - # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 - viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) - author = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "author", "--jq", ".author.login"] - ) - is_own_pr = bool(viewer) and viewer == author + base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) if is_own_pr: info(f"⚠ 自分の Pull Request です(作成者 {author})— 投稿は COMMENT へ倒します") - head_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] - ) - base_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "baseRefName", "--jq", ".baseRefName"] - ) root = ( pathlib.Path(args.worktree_root).resolve() if args.worktree_root @@ -2170,6 +2191,63 @@ def _verify_fix_commits( return problems, accepted +def _mark_resolved_fix_findings(entry: dict[str, Any], resolved: set[str]) -> None: + """GitHub 側で解決済みになった thread に対応する指摘へ、解決の印を付ける。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + +def _record_accepted_fix_commits( + state: dict[str, Any], accepted: list[tuple[str, str]] +) -> None: + """検証を通った修正コミットを、対応する改善項目へ紐づける。 + + 見送り済みなどで項目が見つからないコミットは、紐づけ先が無いので飛ばす。 + """ + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + + +def _revert_invalid_fix_round( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> set[str]: + """検証を通らない修正ラウンドの範囲を取り消し、採用する解決スレッドを返す。 + + 取り消した以上、解決の申告も採らないので**常に空集合を返す**。 + """ + work = state["worktrees"]["work"] + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + 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}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return set() + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2226,39 +2304,11 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - 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}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() + resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + _record_accepted_fix_commits(state, accepted) - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True + _mark_resolved_fix_findings(entry, resolved) merged_keys.append(merge_key) entry["fix_rounds"] += 1 @@ -3133,6 +3183,51 @@ def _require_clean_worktree(state: dict[str, Any], work: str) -> None: ) +def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: + """同期コマンドを実行する。失敗したら差分を捨てて中断する。 + + **黙って push しない。** 同期できない状態を公開すると、利用者のリポジトリの + 検査を壊したまま進むことになる。 + """ + code, timed_out = _run_with_timeout( + command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + ) + if not (timed_out or code != 0): + return + # **途中まで書き換えた差分を残さない。** 残すと次の実行は + # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が + # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 + # ここにある変更は全て同期が作ったものだと分かる。 + _discard_worktree_changes(work) + die( + f"生成物の同期に失敗しました({command}): " + + ("打ち切りました" if timed_out else f"終了コード {code}") + + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" + ) + + +def _commit_sync_changes(work: str, command: str, produced: list[str]) -> None: + """同期が作った差分を進行側のコミットとして積む。差分が無ければ何もしない。 + + このコミットはどの改善項目にも属さない。取り消しでは積み直されないが、 + 次の push で作り直されるので失われても問題にならない。 + """ + if not produced: + return + # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で + # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で + # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は + # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを + # 確認済みだからである。 + try: + _sh(["git", "add", "--", *produced], cwd=work) + _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) + except SystemExit: + _discard_worktree_changes(work) + raise + info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + + def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3154,35 +3249,8 @@ def _sync_generated(state: dict[str, Any]) -> None: # **同期の前に作業ツリーが綺麗であることを求める。** 汚れたまま同期すると、 # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) - code, timed_out = _run_with_timeout( - command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) - ) - if timed_out or code != 0: - # **途中まで書き換えた差分を残さない。** 残すと次の実行は - # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が - # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 - # ここにある変更は全て同期が作ったものだと分かる。 - _discard_worktree_changes(work) - die( - f"生成物の同期に失敗しました({command}): " - + ("打ち切りました" if timed_out else f"終了コード {code}") - + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" - ) - produced = _dirty_paths(state, work) - if not produced: - return - # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で - # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で - # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は - # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを - # 確認済みだからである。 - try: - _sh(["git", "add", "--", *produced], cwd=work) - _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) - except SystemExit: - _discard_worktree_changes(work) - raise - info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + _run_sync_command(state, work, command) + _commit_sync_changes(work, command, _dirty_paths(state, work)) def _push_head(state: dict[str, Any]) -> None: diff --git a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py index 5f23eb2..685de46 100755 --- a/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf-kiro/skills/cross-refactoring/scripts/refactor.py @@ -514,16 +514,21 @@ def verify_commit_trailers(commit: dict[str, Any]) -> Optional[str]: return None -def verify_fix_commit( - commit: dict[str, Any], scope: Optional[Iterable[str]] = None +def _verify_commit_basics( + commit: dict[str, Any], + scope: Optional[Iterable[str]], + missing_reason: str, ) -> Optional[str]: - """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + """コミット 1 件が手順を満たしているかを検証する。問題があれば理由を返す。 - 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という - 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + 適用(`verify_apply_item`)と修正(`verify_fix_commit`)で**同じ基準**を使う。 + 片方だけ直されると基準が食い違い、緩い側から手順を外れた変更が入る。 + + 実体が無いときの理由文だけは呼び出し側から渡す。範囲の呼び方が適用 + (base..head)と修正(修正ラウンドの範囲)で違うためである。 """ if not commit.get("exists", True): - return f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません" + return missing_reason problem = verify_commit_trailers(commit) if problem: return problem @@ -538,6 +543,21 @@ def verify_fix_commit( return None +def verify_fix_commit( + commit: dict[str, Any], scope: Optional[Iterable[str]] = None +) -> Optional[str]: + """修正コミットを適用と同じ基準で検証する。問題があれば理由を返す。 + + 適用側だけ厳しくして修正側を素通しにすると、**レビュー指摘への対応という + 名目で手順を外れた変更が入り、そのまま収束済みになる**。 + """ + return _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が対象の範囲に存在しません", + ) + + def diff_budget_factor(technique: Optional[str]) -> int: """その手法に許す差分予算の倍率。 @@ -562,22 +582,14 @@ def verify_apply_item( return "コミットが 1 件もありません(1 手 1 コミットの前提を満たしていません)" for commit in facts: - if not commit.get("exists", True): - return ( - f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" - "(申告だけで実体がありません)" - ) - problem = verify_commit_trailers(commit) - if problem: - return problem - problem = verify_scope(commit, scope or []) + problem = _verify_commit_basics( + commit, + scope, + f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" + "(申告だけで実体がありません)", + ) if problem: return problem - if commit.get("test_status") != "pass": - return ( - f"コミット {commit.get('sha', '?')} でテストが成功していません " - f"({commit.get('test_status')})" - ) item_id = (commit.get("trailers") or {}).get("Item-Id") if item_id != item["item_id"]: return ( @@ -895,6 +907,28 @@ def _warn_unmeasurable_models( ) +def _fetch_pr_context(pr: int) -> tuple[str, str, bool, str]: + """GitHub から Pull Request のメタデータを取り、自分の Pull Request かを判定する。 + + 返すのは `(base_branch, head_branch, is_own_pr, author)`。 + """ + # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は + # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの + # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 + viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) + author = _sh( + ["gh", "pr", "view", str(pr), "--json", "author", "--jq", ".author.login"] + ) + is_own_pr = bool(viewer) and viewer == author + head_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "headRefName", "--jq", ".headRefName"] + ) + base_branch = _sh( + ["gh", "pr", "view", str(pr), "--json", "baseRefName", "--jq", ".baseRefName"] + ) + return base_branch, head_branch, is_own_pr, author + + def cmd_init(args: argparse.Namespace) -> None: """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 @@ -923,22 +957,9 @@ def cmd_init(args: argparse.Namespace) -> None: auth = check_auth(sorted(set(runtimes) | set(impl_capable))) repo = _sh(["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]) - # **取得に失敗しても止めない。** bot トークン(Actions の `GITHUB_TOKEN` など)は - # `/user` を読めず `HTTP 403` を返す。この値は自分の Pull Request かどうかの - # 判定にしか使わないので、読めなければ他者の Pull Request として扱えばよい。 - viewer = _sh(["gh", "api", "user", "--jq", ".login"], check=False) - author = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "author", "--jq", ".author.login"] - ) - is_own_pr = bool(viewer) and viewer == author + base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) if is_own_pr: info(f"⚠ 自分の Pull Request です(作成者 {author})— 投稿は COMMENT へ倒します") - head_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "headRefName", "--jq", ".headRefName"] - ) - base_branch = _sh( - ["gh", "pr", "view", str(args.pr), "--json", "baseRefName", "--jq", ".baseRefName"] - ) root = ( pathlib.Path(args.worktree_root).resolve() if args.worktree_root @@ -2170,6 +2191,63 @@ def _verify_fix_commits( return problems, accepted +def _mark_resolved_fix_findings(entry: dict[str, Any], resolved: set[str]) -> None: + """GitHub 側で解決済みになった thread に対応する指摘へ、解決の印を付ける。""" + for review in entry["reviews"]: + for finding in review["findings"]: + if finding.get("thread_id") not in resolved: + continue + finding["resolved"] = True + + +def _record_accepted_fix_commits( + state: dict[str, Any], accepted: list[tuple[str, str]] +) -> None: + """検証を通った修正コミットを、対応する改善項目へ紐づける。 + + 見送り済みなどで項目が見つからないコミットは、紐づけ先が無いので飛ばす。 + """ + for item_id, sha in accepted: + item = _find_item(state, item_id, required=False) + if item is not None: + item.setdefault("commits", []).append(sha) + + +def _revert_invalid_fix_round( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + ordered_range: list[str], +) -> set[str]: + """検証を通らない修正ラウンドの範囲を取り消し、採用する解決スレッドを返す。 + + 取り消した以上、解決の申告も採らないので**常に空集合を返す**。 + """ + work = state["worktrees"]["work"] + # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが + # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 + 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}", + "commits": list(ordered_range)}, + dry_run=False, + ) + # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに + # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を + # 「未申告」と判定し、**取り消しを取り消して**しまう。 + entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) + statefile.save(path, state) + # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは + # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 + info("⚠ 修正を取り消したため、解決の申告は採用しません") + return set() + + def cmd_merge_fix(args: argparse.Namespace) -> None: """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" path, state = _load(args.id) @@ -2226,39 +2304,11 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: ) if unassigned or problems: - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - 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}", - "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = _git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは - # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - info("⚠ 修正を取り消したため、解決の申告は採用しません") - resolved = set() + resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) else: - for item_id, sha in accepted: - item = _find_item(state, item_id, required=False) - if item is not None: - item.setdefault("commits", []).append(sha) + _record_accepted_fix_commits(state, accepted) - for review in entry["reviews"]: - for finding in review["findings"]: - if finding.get("thread_id") not in resolved: - continue - finding["resolved"] = True + _mark_resolved_fix_findings(entry, resolved) merged_keys.append(merge_key) entry["fix_rounds"] += 1 @@ -3133,6 +3183,51 @@ def _require_clean_worktree(state: dict[str, Any], work: str) -> None: ) +def _run_sync_command(state: dict[str, Any], work: str, command: str) -> None: + """同期コマンドを実行する。失敗したら差分を捨てて中断する。 + + **黙って push しない。** 同期できない状態を公開すると、利用者のリポジトリの + 検査を壊したまま進むことになる。 + """ + code, timed_out = _run_with_timeout( + command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + ) + if not (timed_out or code != 0): + return + # **途中まで書き換えた差分を残さない。** 残すと次の実行は + # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が + # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 + # ここにある変更は全て同期が作ったものだと分かる。 + _discard_worktree_changes(work) + die( + f"生成物の同期に失敗しました({command}): " + + ("打ち切りました" if timed_out else f"終了コード {code}") + + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" + ) + + +def _commit_sync_changes(work: str, command: str, produced: list[str]) -> None: + """同期が作った差分を進行側のコミットとして積む。差分が無ければ何もしない。 + + このコミットはどの改善項目にも属さない。取り消しでは積み直されないが、 + 次の push で作り直されるので失われても問題にならない。 + """ + if not produced: + return + # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で + # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で + # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は + # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを + # 確認済みだからである。 + try: + _sh(["git", "add", "--", *produced], cwd=work) + _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) + except SystemExit: + _discard_worktree_changes(work) + raise + info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + + def _sync_generated(state: dict[str, Any]) -> None: """push の直前に生成物を同期し、差分があれば進行側のコミットとして積む。 @@ -3154,35 +3249,8 @@ def _sync_generated(state: dict[str, Any]) -> None: # **同期の前に作業ツリーが綺麗であることを求める。** 汚れたまま同期すると、 # 同期が作った差分と元からあった差分を区別できない。 _require_clean_worktree(state, work) - code, timed_out = _run_with_timeout( - command, work, _safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) - ) - if timed_out or code != 0: - # **途中まで書き換えた差分を残さない。** 残すと次の実行は - # `_require_clean_worktree` で必ず止まり、`pending_push` の再試行が - # 永久に進まなくなる。着手前が綺麗だったことは確認済みなので、 - # ここにある変更は全て同期が作ったものだと分かる。 - _discard_worktree_changes(work) - die( - f"生成物の同期に失敗しました({command}): " - + ("打ち切りました" if timed_out else f"終了コード {code}") - + "。同期が作った差分は破棄したので、原因を直せばそのまま再開できます" - ) - produced = _dirty_paths(state, work) - if not produced: - return - # **後段で落ちたときも差分を残さない。** `git add` / `git commit` の失敗で - # 作業ツリーを汚したまま中断すると、次の実行は `_require_clean_worktree` で - # 必ず止まり、`pending_push` の再試行が永久に進まない。捨ててよい根拠は - # 同期コマンド自身が失敗したときと同じで、着手前が綺麗だったことを - # 確認済みだからである。 - try: - _sh(["git", "add", "--", *produced], cwd=work) - _sh(["git", "commit", "-m", SYNC_COMMIT_MESSAGE], cwd=work) - except SystemExit: - _discard_worktree_changes(work) - raise - info(f"🔧 生成物を同期しました({command} / {len(produced)} ファイル)") + _run_sync_command(state, work, command) + _commit_sync_changes(work, command, _dirty_paths(state, work)) def _push_head(state: dict[str, Any]) -> None: