From eb468965806d29c537c53a29dbb0b7c59ce9c27b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 12:57:27 +0000 Subject: [PATCH 01/19] =?UTF-8?q?Docs:=20#880=20#883=20#494=20#723=20cross?= =?UTF-8?q?-refactoring=20=E3=81=AE=E6=98=AF=E6=AD=A3=E3=81=AE=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E8=A8=88=E7=94=BB=E3=82=92=E7=BD=AE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- issues/issue-880-883-494-723-plan.md | 88 ++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 issues/issue-880-883-494-723-plan.md diff --git a/issues/issue-880-883-494-723-plan.md b/issues/issue-880-883-494-723-plan.md new file mode 100644 index 00000000..b10dd02b --- /dev/null +++ b/issues/issue-880-883-494-723-plan.md @@ -0,0 +1,88 @@ +# #880 / #883 / #494 / #723: cross-refactoring の是正 — 実装計画(PR 2) + +## 関連リンク + +- 要求: [issue-880-885-requirements.md](issue-880-885-requirements.md) +- 設計: [issue-880-885-design.md](issue-880-885-design.md)(「実装の分け方と順序」の PR 2 の行) +- 決定: [issue-880-885-design-decisions.md](issue-880-885-design-decisions.md) +- 設計 PR #911(関門 1 承認 2026-09-23、develop `ff9754e7`) + +## モード + +standard(起動指示のとおり。設計は承認済みで、この PR は実装だけを載せる)。 + +## 目的と非目的 + +達成したい状態: +- 群の検証が範囲のテストだけを走らせ、全体テストは `init` と最終ゲートの 2 回に寄る(#880) +- 打ち切りが、子が終わった時点で戻る(#883) +- 構造改善を飛ばしてよいかを差分から判定できる(#494) +- `.md` の文言を固定するテストが提案・適用ラウンドで採られない(#723) + +やらないこと: +- `.md` の文言固定テストの削除・検査スクリプト・`tdd-cycle`・`quality-gates`・`AGENTS.md`(PR 1、#885 の担当) +- AC19a〜AC19c(PR 1)と AC20〜AC23(次の検査の持ち場で PR 1 の取り込み後に測る) +- `waiting.md`・`agent-layers.md`(#892 #901 が触る)、`CLAUDE.md` の cross-review の節 + +## 受け入れ条件 + +要求文書の AC1〜AC18(AC19 系は PR 1)と AC24。検証手段は設計の「テスト設計」の表に従う。 +AC8・AC15・AC16 は文書を読んで確かめる(文言を固定するテストは書かない)。 + +## 修正対象 + +- `plugins/ndf/skills/cross-refactoring/scripts/refactor.py` +- `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/{setup,converge,gate}.py` +- `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/assess.py`(新設) +- `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/{gitfacts,scope,proposals,verify}.py` +- `plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md` +- `plugins/ndf/skills/cross-refactoring/SKILL.md` +- `CLAUDE.md` の cross-refactoring の節 +- `plugins/ndf/skills/development-workflow/references/{workflow-modes,stage-notes}.md` +- `plugins/ndf/skills/cross-refactoring/tests/`(テストの追加・待ちの短縮) + +## タスク分解 + +### Task 1: 打ち切りが子の終了で戻る(#883) +- **対象:** `gitfacts.py` の `_kill_process_group`、`tests/test_git_facts.py` +- **変更:** SIGTERM 後の点検で `proc.poll()` を先に呼ぶ。既存 3 件の確認の待ちを 1.5 秒へ縮め、`exec sleep 30` のテストを足す +- **満たす AC:** AC9・AC10・AC11 +- **進め方:** `exec sleep 30` が 2 秒以内に戻るテストを先に書いて落とす → 1 行の修正 + +### Task 2: `assess` の副命令(#494) +- **対象:** `gitfacts.py` の `production_code_changes`、`commands/assess.py`、`refactor.py`、テスト +- **変更:** `git diff --numstat --no-renames ...HEAD` から本番コードを数え、終了コード 0 / 3 / 2 と 3 行を出す +- **満たす AC:** AC12・AC13・AC14 +- **進め方:** 一時リポジトリで各差分を作るテストを先に書く → 実装 + +### Task 3: `--round-test` と検証の秒数(#880) +- **対象:** `refactor.py` の `init` 引数、`commands/setup.py`・`converge.py`・`gate.py`、`scope.py` の `round_test_hint`・`round_test_roots`、テスト +- **変更:** 状態の `round_test` の保存・`init` での実行・`_round_test_command(state)`・`verify-round` と修正コミットの検証の差し替え・`seconds` の記録・単独起動の最終ゲートの全体テスト 1 回・案内の 1 行 +- **満たす AC:** AC1〜AC7 +- **進め方:** AC ごとに失敗するテスト → 実装 + +### Task 4: `.md` の文言固定テストを採らない(#723) +- **対象:** `proposals.py` の `merge_test_proposals`、`verify.py` の `doc_wording_tests` と `verify_apply_round`、`prompts/propose-tests.md`、テスト +- **満たす AC:** AC16・AC17・AC18 +- **進め方:** 見送り・失敗のテストを先に書く → 実装。`propose-tests.md` は文書の追記 + +### Task 5: 文書(#880 #494) +- **対象:** `SKILL.md`・`CLAUDE.md`・`workflow-modes.md`・`stage-notes.md` +- **満たす AC:** AC8・AC15 +- **進め方:** テスト駆動を適用しない(文書。文言を固定するテストは書かない) + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `gitfacts.py`(1182 行)・`apply.py`(1156 行)が大きい | 実装の後の構造改善で足りる(触るのは関数単位で狭く、テストが厚い)。タスクごとに cross-refactoring のテストを通す | +| 既存の `.md` 文言固定テストが文書の変更で落ちる | 削除は PR 1 の担当。この PR の変更で壊れたものだけを最小に直す | + +## 切り戻し手順 + +コードと文書だけの変更で、状態ファイルは `round_test` が無ければ変更前の振る舞いに戻る(AC7)。PR の revert で戻せる。 + +## 完了の定義 + +- [ ] AC1〜AC18(AC19 系を除く)を満たし、条件ごとにテストか文書の節が対応する +- [ ] AC24: `uv run --project plugins/playwright-kit/skills/playwright-kit-ops --with pytest pytest . -q -n 4` が通る From fd625913a100e460dca8771b4f22576de7f11f09 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 12:59:02 +0000 Subject: [PATCH 02/19] =?UTF-8?q?Fix:=20#883=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AE=E6=89=93=E3=81=A1=E5=88=87=E3=82=8A=E3=81=8C=E3=80=81?= =?UTF-8?q?=E5=AD=90=E3=81=8C=E7=B5=82=E3=82=8F=E3=81=A3=E3=81=9F=E6=99=82?= =?UTF-8?q?=E7=82=B9=E3=81=A7=E6=88=BB=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIGTERM の後の点検で親シェルを先に回収する。回収しないとゾンビがグループに 残り、子がすべて終わっていても猶予の 5 秒を毎回待っていた。判定はグループの 存否のまま変えない(SIGTERM を無視する子には SIGKILL が届く)。 打ち切りの 3 件のテストの確認の待ちを 1.5 秒へ縮める(合計 約 7.5 秒)。 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- .../scripts/refactor_lib/gitfacts.py | 3 +++ .../cross-refactoring/tests/test_git_facts.py | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index f4b19107..2ff5fcf5 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -262,6 +262,9 @@ def _kill_process_group( deadline = time.monotonic() + grace while time.monotonic() < deadline: + # 親シェルを先に回収する。回収しないとゾンビがグループに残り、 + # 子がすべて終わっていても猶予を最後まで待つ(#883) + proc.poll() if not _process_group_alive(pgid): return time.sleep(0.2) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py index 6b5e65b1..eacd271d 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py @@ -231,6 +231,22 @@ def test_hanging_test_is_cut_off(gitfacts, work): assert _git("rev-parse", "--abbrev-ref", "HEAD", cwd=work).stdout.strip() == "main" +def test_cutting_off_returns_once_the_group_is_gone(gitfacts, work): + """SIGTERM で子が終われば、猶予を待たずに戻る(#883)。 + + 親シェルを回収しないとゾンビがグループに残り、猶予の 5 秒を毎回待つ。 + """ + import time + + started = time.monotonic() + code, timed_out = gitfacts.run_with_timeout("exec sleep 30", str(work), 1) + elapsed = time.monotonic() - started + + assert timed_out is True + assert code is None + assert elapsed < 2, f"打ち切りの後に猶予を待っている: {elapsed:.1f}s" + + def test_cutting_off_a_test_kills_its_children(gitfacts, work): """打ち切るときは**子プロセスまで**止めること。 @@ -248,7 +264,7 @@ def test_cutting_off_a_test_kills_its_children(gitfacts, work): status = gitfacts.run_test_at(str(work), sha, command, "main", timeout=1) assert status == "fail" - time.sleep(3) + time.sleep(1.5) assert not marker.exists(), "子プロセスが生き残って書き込んでいる" @@ -273,7 +289,7 @@ def test_cutting_off_kills_children_that_ignore_sigterm(gitfacts, work): assert status == "fail" assert elapsed < 20, f"打ち切りに時間がかかりすぎている: {elapsed:.1f}s" - time.sleep(4) + time.sleep(1.5) assert not marker.exists(), "SIGTERM を無視する子が生き残っている" From 11ce27d2d5d8cb8c7870112ad0c80f6b7bf58ab2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 13:01:16 +0000 Subject: [PATCH 03/19] =?UTF-8?q?Add:=20#494=20=E6=A7=8B=E9=80=A0=E6=94=B9?= =?UTF-8?q?=E5=96=84=E3=82=92=E9=A3=9B=E3=81=B0=E3=81=97=E3=81=A6=E3=82=88?= =?UTF-8?q?=E3=81=84=E3=81=8B=E3=82=92=E5=B7=AE=E5=88=86=E3=81=8B=E3=82=89?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E3=81=99=E3=82=8B=20assess=20=E3=82=92?= =?UTF-8?q?=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor.py に副命令 assess(--base REF、--max-lines N(既定 10))を登録する - gitfacts.py に production_code_changes を足す。git diff --numstat -z --no-renames ...HEAD を読み、CODE_EXTENSIONS に当たり _is_test_path が偽のファイルと 変更行(追加 + 削除)を返す。rename は旧パスと新パスの両方で判定する - CODE_EXTENSIONS は cross-refactoring に無かったため、設計の一覧どおりに gitfacts.py へ置く - 終了コードは 0 = 通す / 3 = 飛ばしてよい / 2 = 起点を解けない。出力は 判定・理由・本番コード の 3 行 - tests/test_assess.py に一時リポジトリで AC12〜AC14 を確かめるテストを 8 件足す Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- .../cross-refactoring/scripts/refactor.py | 12 ++ .../scripts/refactor_lib/commands/assess.py | 48 +++++++ .../scripts/refactor_lib/gitfacts.py | 31 +++++ .../cross-refactoring/tests/test_assess.py | 119 ++++++++++++++++++ 4 files changed, 210 insertions(+) create mode 100644 plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/assess.py create mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_assess.py diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py index 01df6cf5..fdc3f6f2 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py @@ -49,6 +49,7 @@ cmd_merge_proposals, cmd_next_apply_round, ) +from refactor_lib.commands.assess import DEFAULT_MAX_LINES, cmd_assess # noqa: E402 from refactor_lib.commands.converge import ( # noqa: E402 cmd_abandon_items, cmd_merge_fix, @@ -221,6 +222,17 @@ def main() -> None: help="取り消すコミットを表示するだけで実行しない") sp.set_defaults(func=func) + ap = sub.add_parser( + "assess", + help="構造改善を飛ばしてよいかを差分から判定する。" + "終了コード 0 = 通す / 3 = 飛ばしてよい / 2 = 判定できない") + ap.add_argument("--base", required=True, + help="起点の ref。`...HEAD` の差分を見る") + ap.add_argument("--max-lines", type=int, default=DEFAULT_MAX_LINES, + help="本番コードの変更行(追加 + 削除)がこれ以下なら飛ばしてよい " + f"(default: {DEFAULT_MAX_LINES})") + ap.set_defaults(func=cmd_assess) + rp = sub.add_parser( "report", help="Step 8 — ラウンド表・項目表・見送り・指標") rp.add_argument("id", type=int) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/assess.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/assess.py new file mode 100644 index 00000000..fa307116 --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/assess.py @@ -0,0 +1,48 @@ +"""構造改善を飛ばしてよいかを、差分から判定する(#494)。 + +`assess` を持つ。状態ファイルは読まない(`init` より前に呼ぶため)。 +""" +from __future__ import annotations + +import argparse +import os +import sys + +from ..gitfacts import production_code_changes + +DEFAULT_MAX_LINES = 10 + +PASS = 0 +SKIP = 3 +UNDECIDABLE = 2 + + +def cmd_assess(args: argparse.Namespace) -> None: + """`...HEAD` の本番コードの差分から、構造改善を通すか飛ばしてよいかを出す。 + + 終了コード: 0 = 通す / 3 = 飛ばしてよい / 2 = `` を解けない。 + **2 を飛ばしてよいと読まない。** 判定できないことは飛ばす理由にならない。 + + 退避の 3 条件(テストが無い・CLI が使えない・範囲を絞れない)は見ない。それらは + `init` が止めて知らせる。 + """ + changes = production_code_changes(os.getcwd(), args.base) + if changes is None: + print(f"ERROR: 起点 {args.base} から HEAD までの差分を取れません", + file=sys.stderr) + sys.exit(UNDECIDABLE) + total = sum(n for _, n in changes) + if not changes: + verdict, reason = SKIP, "本番コードの差分がありません" + elif total <= args.max_lines: + verdict = SKIP + reason = f"本番コードの変更が {total} 行で、上限 {args.max_lines} 行以下です" + else: + verdict, reason = PASS, f"本番コードの変更が {total} 行です" + counted = f"本番コード: {len(changes)} ファイル・{total} 行" + if changes: + counted += f"({'、'.join(p for p, _ in changes)})" + print("判定: " + ("通す" if verdict == PASS else "飛ばしてよい")) + print("理由: " + reason) + print(counted) + sys.exit(verdict) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index 2ff5fcf5..e9147423 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -36,6 +36,12 @@ TEST_PATH_MARKERS = ("/test/", "/tests/", "/spec/", "/specs/", "__tests__/") TEST_NAME_MARKERS = (".test.", ".spec.", "_test.", "_spec.", "test_", "spec_") +# 本番コードの拡張子。構造改善を飛ばしてよいかの判定(`assess`)に使う(#494)。 +CODE_EXTENSIONS = frozenset({ + ".py", ".sh", ".bash", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".php", + ".rb", ".go", ".rs", ".java", ".kt", ".swift", ".c", ".h", ".cc", ".cpp", ".cs", +}) + def safe_int(value: Any, fallback: int = 0) -> int: """LLM が返した値を int にする。数値として読めなければ `fallback`。 @@ -190,6 +196,31 @@ def _is_test_path(path: str) -> bool: or any(m in name for m in TEST_NAME_MARKERS)) +def production_code_changes(work: str, base: str) -> Optional[list[tuple[str, int]]]: + """`...HEAD` の差分のうち、本番コードのファイルと変更行(追加 + 削除)を返す。 + + `` を解けないときは `None`。**`--no-renames` を付ける。** 付けないと rename が + `dir/{old.py => new.py}` の形になり、拡張子で判定できない。付ければ旧パスの削除と + 新パスの追加に分かれ、両方のパスで判定できる。`-z` は、ASCII 以外を含むパスが + 引用符付きで出て拡張子が読めなくなるのを防ぐ。 + """ + out = git_out(work, ["diff", "--numstat", "-z", "--no-renames", f"{base}...HEAD"]) + if out is None: + return None + changes: list[tuple[str, int]] = [] + for line in out.split("\0"): + parts = line.split("\t") + if len(parts) < 3: + continue + path = parts[2] + if (pathlib.PurePosixPath(path).suffix.lower() not in CODE_EXTENSIONS + or _is_test_path(path)): + continue + # バイナリは `-` になるので数えない + changes.append((path, sum(int(n) for n in parts[:2] if n.isdigit()))) + return changes + + def commit_touches_tests(work: str, sha: str) -> bool: """コミットがテストの置き場所を触っているか。""" return any(_is_test_path(p) for p in commit_files(work, sha)) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_assess.py b/plugins/ndf/skills/cross-refactoring/tests/test_assess.py new file mode 100644 index 00000000..66365fdc --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_assess.py @@ -0,0 +1,119 @@ +"""`refactor.py assess` を、**実際の git リポジトリ**と入口の CLI で確かめる(#494 の AC12〜AC14)。 + +起点のコミットに対して差分を 1 つ積み、終了コード(0 = 通す / 3 = 飛ばしてよい / +2 = 判定できない)と、出力の 3 行(`判定:` / `理由:` / `本番コード:`)を見る。 +""" +from __future__ import annotations + +import pathlib +import subprocess +import sys + +import pytest + +_SCRIPT = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "refactor.py" + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, + text=True, check=True) + + +def _lines(n: int, prefix: str = "x") -> str: + return "".join(f"{prefix}{i} = {i}\n" for i in range(n)) + + +@pytest.fixture +def repo(tmp_path): + """起点のコミット(ブランチ `base`)だけがある作業ディレクトリ。""" + work = tmp_path / "repo" + work.mkdir() + _git("init", "-q", "-b", "main", cwd=work) + _git("config", "user.email", "t@e.st", cwd=work) + _git("config", "user.name", "test", cwd=work) + (work / "src").mkdir() + (work / "src" / "a.py").write_text(_lines(12, "a")) + (work / "README.md").write_text("# readme\n") + _git("add", "-A", cwd=work) + _git("commit", "-qm", "init", cwd=work) + _git("branch", "base", cwd=work) + return work + + +def _commit(work: pathlib.Path, files: dict[str, str]) -> None: + for rel, body in files.items(): + path = work / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + _git("add", "-A", cwd=work) + _git("commit", "-qm", "change", cwd=work) + + +def _assess(work: pathlib.Path, *extra: str) -> tuple[int, list[str]]: + r = subprocess.run([sys.executable, str(_SCRIPT), "assess", *extra], + cwd=work, capture_output=True, text=True) + return r.returncode, r.stdout.splitlines() + + +@pytest.mark.parametrize("files", [ + {"docs/guide.md": "# guide\n" + _lines(30)}, + {"tests/test_a.py": _lines(30, "t")}, + {"config/settings.json": "{\n" + "".join(f'"k{i}": {i},\n' for i in range(30)) + "}\n"}, +], ids=["md-only", "tests-only", "json-only"]) +def test_no_production_code_may_be_skipped(repo, files): + _commit(repo, files) + rc, out = _assess(repo, "--base", "base") + assert rc == 3 + assert out == [ + "判定: 飛ばしてよい", + "理由: 本番コードの差分がありません", + "本番コード: 0 ファイル・0 行", + ] + + +def test_ten_lines_of_production_code_may_be_skipped(repo): + _commit(repo, {"src/b.py": _lines(10, "b")}) + rc, out = _assess(repo, "--base", "base") + assert rc == 3 + assert out == [ + "判定: 飛ばしてよい", + "理由: 本番コードの変更が 10 行で、上限 10 行以下です", + "本番コード: 1 ファイル・10 行(src/b.py)", + ] + + +def test_eleven_lines_of_production_code_pass(repo): + _commit(repo, {"src/b.py": _lines(11, "b"), "README.md": _lines(50)}) + rc, out = _assess(repo, "--base", "base") + assert rc == 0 + assert out == [ + "判定: 通す", + "理由: 本番コードの変更が 11 行です", + "本番コード: 1 ファイル・11 行(src/b.py)", + ] + + +def test_rename_counts_both_paths(repo): + """rename は旧パスの削除と新パスの追加として、両方のパスで判定する。""" + _git("mv", "src/a.py", "src/b.py", cwd=repo) + _commit(repo, {"src/b.py": _lines(12, "a") + "extra = 1\n"}) + rc, out = _assess(repo, "--base", "base") + assert rc == 0 + assert out == [ + "判定: 通す", + "理由: 本番コードの変更が 25 行です", + "本番コード: 2 ファイル・25 行(src/a.py、src/b.py)", + ] + + +def test_max_lines_changes_the_limit(repo): + _commit(repo, {"src/b.py": _lines(11, "b")}) + rc, out = _assess(repo, "--base", "base", "--max-lines", "20") + assert rc == 3 + assert out[1] == "理由: 本番コードの変更が 11 行で、上限 20 行以下です" + + +def test_unresolvable_base_exits_2(repo): + rc, out = _assess(repo, "--base", "no-such-ref") + assert rc == 2 + assert out == [] From 9553e350a37c2b60d698ca47a46c20dfbfff7116 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 13:07:57 +0000 Subject: [PATCH 04/19] =?UTF-8?q?Update:=20#880=20=E7=BE=A4=E3=81=AE?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=E3=82=92=20--round-test=20=E3=81=A7=E8=A1=8C?= =?UTF-8?q?=E3=81=84=E3=80=81=E5=85=A8=E4=BD=93=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E7=9D=80=E6=89=8B=E5=89=8D=E3=81=A8=E6=9C=80=E7=B5=82?= =?UTF-8?q?=E3=82=B2=E3=83=BC=E3=83=88=E3=81=B8=E5=AF=84=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor.py init に --round-test CMD を足し、--baseline-test の help を「着手前と最終ゲートで実行する全体のテスト」へ直す - init は状態へ round_test(command・status)を保存する。省くか全体テストと同じ文字列なら全体テストを 1 回だけ実行して結果を写し、違えば全体テストの後に 1 回実行して失敗(終了コード 5 を含む)で止める - --scope の関門は --round-test があればその実行集合(scope.round_test_roots)で判定する。オプションの値・作業ディレクトリの根・ラッパーのスクリプトは起点に数えない - --round-test が無く --baseline-test が範囲より広いとき、1 行の案内を出して続ける(scope.round_test_hint) - verify-round と merge-fix の修正コミットの検証は scope.round_test_command(state) を実行する。round_test の無い状態ファイルは baseline_test を返す - 最終ゲートは単独起動でも round_test が baseline_test と違えば先に全体テストを 1 回通す(--ci-check があれば継続的統合で代える) - verifications と final_gate.checks の各件へ seconds(final_gate は command も)を足す - scope から使うため gitfacts._is_test_path を公開名 is_test_path へ改名する Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- .../cross-refactoring/scripts/refactor.py | 8 +- .../scripts/refactor_lib/commands/converge.py | 26 +++-- .../scripts/refactor_lib/commands/gate.py | 32 ++++- .../scripts/refactor_lib/commands/setup.py | 50 +++++++- .../scripts/refactor_lib/gitfacts.py | 8 +- .../scripts/refactor_lib/scope.py | 109 ++++++++++++++++-- .../tests/test_abandon_items.py | 29 +++++ .../tests/test_final_gate.py | 93 +++++++++++++++ .../cross-refactoring/tests/test_init.py | 92 +++++++++++++++ .../tests/test_scope_gate.py | 95 +++++++++++++++ .../tests/test_verify_round.py | 47 ++++++++ 11 files changed, 563 insertions(+), 26 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py index fdc3f6f2..c838ef35 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py @@ -164,8 +164,14 @@ def main() -> None: "ラウンドが進むたびに同じコメントを編集する。" "空文字を渡すと記録しない") init.add_argument("--baseline-test", required=True, - help="着手前と各コミットで実行するテストコマンド。" + help="着手前と最終ゲートで実行する全体のテスト。" "振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須") + # **群と修正コミットの検証は範囲のテストで行う**(#880)。全体テストを群ごとに + # 走らせると、群と修正コミットの数だけ費用が積み上がる。 + init.add_argument("--round-test", default=None, metavar="CMD", + help="群の検証と修正コミットごとに実行する範囲のテスト。" + "--scope のテストの置き場所を走らせること。" + "省くと --baseline-test と同じ") # **起動のされ方は引数で受け取る**(#436 決定 7)。環境変数や控えの読み取りは、 # 起動元が違っても同じ値になりうる。呼ぶ側が明示すれば判定が 1 か所で済む。 init.add_argument("--workflow-step", action="store_true", default=None, diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index ecbeff79..8cd4137d 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -11,6 +11,7 @@ import hashlib import pathlib import sys +import time from typing import Any, Optional import statefile @@ -46,6 +47,7 @@ phase_after_group, prepare_fix_phase, ) +from ..scope import round_test_command from ..verify import ( unassigned_fix_commits, verify_commit_granularity, @@ -61,11 +63,15 @@ def _verification_record( code: int, timed_out: bool, passed: bool, + seconds: float, ) -> dict[str, Any]: """検証 1 回分の記録を作る。**状態は変えない。** 判定を作る段と、判定を状態へ反映する段を分ける。合否そのものは呼び出し側が 決めており、ここは何を記録に残すかだけを持つ。 + + **所要の秒数を残す**(#880)。範囲のテストへ寄せた効果は、状態ファイルから + 測れなければ確かめられない。 """ return { "apply_round": group["apply_round"], @@ -75,6 +81,7 @@ def _verification_record( "status": "pass" if passed else "fail", "exit_code": code, "timed_out": timed_out, + "seconds": seconds, } @@ -128,8 +135,9 @@ def cmd_verify_round(args: argparse.Namespace) -> None: 終了コード: 0 = テストが通った / 2 = 落ちた(修正ラウンドへ)。 - **2 CLI のレビューは起動しない**(決定 3)。`--baseline-test` が指す - コマンドを作業ディレクトリの HEAD で実行し、その合否で決める。 + **2 CLI のレビューは起動しない**(決定 3)。`--round-test` が指す範囲の + テストを作業ディレクトリの HEAD で実行し、その合否で決める(#880)。全体の + テストは着手前と最終ゲートにしか走らせない。 **失敗をどの項目に紐づけるかは決めない。** 適用ラウンドの中は 1 コミットで あり、分離しても取り消せない。判定の単位と取り消しの単位を一致させる。 @@ -148,14 +156,16 @@ def cmd_verify_round(args: argparse.Namespace) -> None: code=2, ) - command = (state.get("baseline_test") or {}).get("command") or "" + command = round_test_command(state) work = str(state["worktrees"]["work"]) timeout = safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + started = time.monotonic() code, timed_out = run_with_timeout(command, work, timeout) + seconds = round(time.monotonic() - started, 1) passed = (not timed_out) and code == 0 entry.setdefault("verifications", []).append( - _verification_record(group, entry, command, code, timed_out, passed) + _verification_record(group, entry, command, code, timed_out, passed, seconds) ) if passed: @@ -429,20 +439,21 @@ def _inspect_fix_commits( state: dict[str, Any], work: str, payload: dict[str, Any], - baseline: dict[str, Any], ordered_range: list[str], ) -> tuple[list[str], list[str], list[tuple[str, str]]]: """修正コミットを **git と実際のテスト実行から**検証する。 結果ファイルの申告で済ませると、手順を満たさない変更が収束済みになれてしまう。 未割当コミットの一覧・問題点の一覧・受理した (item_id, sha) を返す。 + + **コミットごとに走らせるのは範囲のテストである**(#880)。 """ claimed_shas = reported_shas(payload) unassigned = unassigned_fix_commits(work, claimed_shas, ordered_range) facts = collect_commit_facts( work, claimed_shas, set(ordered_range), - baseline.get("command") or "true", state["head_branch"], + round_test_command(state) or "true", state["head_branch"], safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) @@ -555,10 +566,9 @@ def _confirm_and_settle_fix( 採用した解決スレッドの集合を返す(取り込みの通知に使う)。 """ resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) - baseline = state.get("baseline_test") or {} ordered_range = _resolve_fix_range(path, state, entry, work, head_now) unassigned, problems, accepted = _inspect_fix_commits( - state, work, payload, baseline, ordered_range + state, work, payload, ordered_range ) _settle_fix_round( path, state, entry, scope, ordered_range, resolved, unassigned, problems, diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index 85f2dd20..16d45440 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -17,6 +17,7 @@ import argparse import pathlib import sys +import time from typing import Any, Optional import statefile @@ -42,6 +43,7 @@ ) from ..paths import git_out, load_state from ..rounds import impl_for_seq +from ..scope import round_test_command from ..verify import verify_final_fix_commit from ..vocabulary import DEFAULT_TEST_TIMEOUT from ..verify import unassigned_fix_commits @@ -55,11 +57,16 @@ def cmd_final_gate(args: argparse.Namespace) -> None: **Step 7 は push 済みの地点である。** 上限に達しても取り消さない。取り消しの 判断は Pull Request の読み手が持つため、失敗として報告に書く。 + + **単独起動でも、群を範囲のテストで検証してきたなら全体のテストを 1 回通す** + (#880)。範囲の外への波及を見る機会が、ほかに無いためである。通れば今のとおり + `cross-review` へ渡し、落ちれば工程として起動したときと同じ修正ラウンドへ入る。 """ path, state = load_state(args.id) gate = state.setdefault("final_gate", {"fix_rounds": 0, "checks": []}) + standalone = not state.get("workflow_step") - if not state.get("workflow_step"): + if standalone and not _round_test_differs(state): gate["mode"] = "cross-review" statefile.save(path, state) info("単独起動のため、Step 7 は /ndf:cross-review を実行します") @@ -71,16 +78,26 @@ def cmd_final_gate(args: argparse.Namespace) -> None: # とはしない(OR で採ると、手元のテストの失敗を継続的統合の成功が覆す)。 ci_check = str(state.get("ci_check") or "").strip() gate["mode"] = "ci" if ci_check else "test" + started = time.monotonic() passed, detail = ( _ci_gate(state, ci_check) if ci_check else _local_gate(state) ) gate.setdefault("checks", []).append({ "at": statefile.now(), "mode": gate["mode"], + "command": ci_check or _baseline_command(state), "status": "pass" if passed else "fail", "detail": detail, + "seconds": round(time.monotonic() - started, 1), }) + if passed and standalone: + gate["mode"] = "cross-review" + statefile.save(path, state) + info(f"✅ 全体のテストが通りました({detail})。Step 7 は /ndf:cross-review を実行します") + statefile.emit(FINAL_GATE="cross-review") + return + if passed: gate["status"] = "passed" statefile.save(path, state) @@ -119,6 +136,15 @@ def cmd_final_gate(args: argparse.Namespace) -> None: sys.exit(2) +def _baseline_command(state: dict[str, Any]) -> str: + return str((state.get("baseline_test") or {}).get("command") or "") + + +def _round_test_differs(state: dict[str, Any]) -> bool: + """群の検証が全体のテストと違うコマンドで行われたか(#880)。""" + return round_test_command(state) != _baseline_command(state) + + def _final_fix_impl(state: dict[str, Any], gate: dict[str, Any]) -> str: """最終ゲートの修正担当を決める。**最初に落ちたときだけ輪番を 1 つ進める。** @@ -326,8 +352,8 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: def _local_gate(state: dict[str, Any]) -> tuple[bool, str]: - """全体のテストを手元で実行する。""" - command = (state.get("baseline_test") or {}).get("command") or "" + """全体のテストを手元で実行する。**全体のテストを呼ぶのは `init` とここだけである。**""" + command = _baseline_command(state) work = str(state["worktrees"]["work"]) timeout = safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) code, timed_out = run_with_timeout(command, work, timeout) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index 6857f63f..a06a9c97 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -39,7 +39,7 @@ impl_for_seq, round_kind, ) -from ..scope import require_scope_covers_tests +from ..scope import require_scope_covers_tests, round_test_hint from ..vocabulary import ( DEFAULT_MAX_TEST_ROUNDS, DEFAULT_SEVERITY_THRESHOLD, @@ -80,6 +80,7 @@ statefile.ResumeField("scope", "target_scope", "notify"), statefile.ResumeField("model", "models", "notify"), statefile.ResumeField("baseline_test", "baseline_test", "notify"), + statefile.ResumeField("round_test", "round_test", "notify"), statefile.ResumeField("ci_check", "ci_check", "notify"), statefile.ResumeField("severity_threshold", "severity_threshold", "notify"), statefile.ResumeField("sync_command", "sync_command", "notify"), @@ -278,6 +279,7 @@ class InitialContext: participants: dict[str, Any] model_spec: dict[str, Optional[str]] baseline: dict[str, Any] + round_test: dict[str, Any] def _build_initial_state( @@ -333,6 +335,8 @@ def _build_initial_state( "round_kind": TEST, "severity_threshold": args.severity_threshold, "baseline_test": ctx.baseline, + # **群と修正コミットの検証が実行するテスト**(#880)。省けば全体テストと同じ。 + "round_test": ctx.round_test, # 生成物の同期は**進行側の責務**。push の直前に実行する。 "sync_command": args.sync_command, # **改修計画の既定は Pull Request のコメント 1 件である**(#436 決定 6)。 @@ -388,7 +392,13 @@ def cmd_init(args: argparse.Namespace) -> None: # ラウンドが足したテストが検証に効かない。案内だけでは同じ失敗を繰り返す # ため、**止める**。作業ディレクトリが要るのは、探索範囲の語がディレクトリか # どうかを実物で確かめるためである。 - require_scope_covers_tests(args.scope, args.baseline_test, str(work)) + # **足したテストが入るべき実行集合は `--round-test` である**(#880)。群の検証が + # 走らせるのはこちらで、全体テストは着手前と最終ゲートにしか走らない。 + round_test = getattr(args, "round_test", None) + if round_test: + require_scope_covers_tests(args.scope, round_test, str(work), round_test=True) + else: + require_scope_covers_tests(args.scope, args.baseline_test, str(work)) tmp_dir = tmp_dir_for(work) tmp_dir.mkdir(parents=True, exist_ok=True) @@ -410,7 +420,12 @@ def cmd_init(args: argparse.Namespace) -> None: host, include or [], exclude or [], bool(getattr(args, "require_all", None))) _warn_unmeasurable_models(model_spec, participants["available"]) + hint = round_test_hint(round_test, args.baseline_test, args.scope, str(work)) + if hint: + info(hint) + baseline = _run_baseline_test(args.baseline_test, work, args.test_timeout) + round_record = _run_round_test(round_test, baseline, work, args.test_timeout) context = InitialContext( repo=repo, @@ -424,6 +439,7 @@ def cmd_init(args: argparse.Namespace) -> None: participants=participants, model_spec=model_spec, baseline=baseline, + round_test=round_record, ) state = _build_initial_state(args, context) # GitHub は自分の Pull Request への `APPROVE` と `REQUEST_CHANGES` を @@ -496,6 +512,7 @@ def _notify_view( """ view = dict(state) view["baseline_test"] = (state.get("baseline_test") or {}).get("command") + view["round_test"] = (state.get("round_test") or {}).get("command") given = argparse.Namespace(**{f.arg: getattr(args, f.arg, None) for f in RESUME_NOTIFY_FIELDS}) if given.model is not None: given.model = model_spec @@ -625,6 +642,35 @@ def _run_baseline_test( return {"command": command, "status": status, "checked_at": statefile.now()} +def _run_round_test( + command: Optional[str], baseline: dict[str, Any], work: pathlib.Path, + timeout: int = DEFAULT_TEST_TIMEOUT, +) -> dict[str, Any]: + """範囲のテストを着手前に 1 回実行して記録する(#880)。 + + **省いたとき、または全体テストと同じ文字列のときは実行しない。** 同じコマンドを + 2 度走らせても判定は変わらず、時間だけが掛かる。全体テストの結果を写す。 + + **失敗は全体テストと別に止める。** 全体テストが通っても範囲のテストが通らない + (テストが 1 件も集まらない終了コード 5 を含む)なら、群の検証が初回から落ちる。 + """ + if not command or command == baseline["command"]: + return {"command": baseline["command"], "status": baseline["status"], + "checked_at": baseline["checked_at"]} + code, timed_out = run_with_timeout(command, str(work), timeout) + if timed_out: + die(f"範囲のテストが {timeout} 秒で終わりませんでした({command})。打ち切りました") + raise SystemExit(ABORT) + if code != 0: + die( + f"範囲のテストが成功しません({command} / 終了コード {code})。" + "--round-test が --scope のテストの置き場所を走らせるかを確かめてください" + ) + raise SystemExit(ABORT) + info(f"✅ 着手前の範囲のテスト成功: {command}") + return {"command": command, "status": "green", "checked_at": statefile.now()} + + def rounds_of_kind(state: dict[str, Any], kind: str) -> list[dict[str, Any]]: """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" return [r for r in state.get("rounds") or [] if entry_kind(r) == kind] diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index e9147423..ad74328f 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -179,7 +179,7 @@ def commit_test_changes(work: str, sha: str) -> dict[str, tuple[list[str], list[ out = git_out(work, ["show", "--name-only", "--format=", sha]) changes: dict[str, tuple[list[str], list[str]]] = {} for path in (out or "").splitlines(): - if not path.strip() or not _is_test_path(path): + if not path.strip() or not is_test_path(path): continue before = git_out(work, ["show", f"{sha}^:{path}"]) or "" after = git_out(work, ["show", f"{sha}:{path}"]) or "" @@ -188,7 +188,7 @@ def commit_test_changes(work: str, sha: str) -> dict[str, tuple[list[str], list[ return changes -def _is_test_path(path: str) -> bool: +def is_test_path(path: str) -> bool: """テストの置き場所か。判定は `commit_touches_tests` と同じ印で行う。""" lowered = f"/{path.lower()}" name = lowered.rsplit("/", 1)[-1] @@ -214,7 +214,7 @@ def production_code_changes(work: str, base: str) -> Optional[list[tuple[str, in continue path = parts[2] if (pathlib.PurePosixPath(path).suffix.lower() not in CODE_EXTENSIONS - or _is_test_path(path)): + or is_test_path(path)): continue # バイナリは `-` になるので数えない changes.append((path, sum(int(n) for n in parts[:2] if n.isdigit()))) @@ -223,7 +223,7 @@ def production_code_changes(work: str, base: str) -> Optional[list[tuple[str, in def commit_touches_tests(work: str, sha: str) -> bool: """コミットがテストの置き場所を触っているか。""" - return any(_is_test_path(p) for p in commit_files(work, sha)) + return any(is_test_path(p) for p in commit_files(work, sha)) def run_with_timeout( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py index c007044c..155fb08e 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py @@ -17,9 +17,10 @@ import os import pathlib import shlex -from typing import Iterable, Optional +from typing import Any, Iterable, Optional from . import die +from .gitfacts import is_test_path # テストの置き場所とみなすディレクトリの名前。**言語をまたいで使われるものだけ**を # 並べる。増やすほど「テストの置き場所がある」と誤って判定して関門が素通りする。 @@ -130,10 +131,101 @@ def covered_by_roots(location: str, roots: list[str]) -> bool: ) +def round_test_roots(command: str, work: str) -> list[str]: + """`--round-test` の実行集合の起点を返す(#880)。空なら全体を走らせるとみなす。 + + `baseline_search_roots` と違い、**テストの置き場所に当たる実在するファイルも起点に + 数える。** 範囲のテストは 1 ファイルを名指しすることがあり、それを限定なしと読むと + 範囲の置き場所を走らせないコマンドが関門を通る。 + + 数えない語は 3 つある。先頭の語(プログラム名)と `-` で始まる語、`=` を含まない + 長いオプションの直後の語(`--project .` の `.`)、作業ディレクトリの根(`.`)で + ある。テストの置き場所でないファイル(`bash scripts/run-scope-tests.sh` の + ラッパー)も数えない。**ラッパーの中身は解析しない。** 範囲の外を走らせても、 + 最終ゲートの全体テストが見る。 + """ + try: + words = shlex.split(str(command or "")) + except ValueError: + return [] + roots: list[str] = [] + previous = words[0] if words else "" + for word in words[1:]: + is_option_value = previous.startswith("--") and "=" not in previous + previous = word + if is_option_value or word.startswith("-") or os.path.isabs(word): + continue + normalized = os.path.normpath(word) + if normalized == ".": + continue + target = pathlib.Path(work) / word + if not (target.is_dir() or (target.is_file() and is_test_path(normalized))): + continue + if normalized not in roots: + roots.append(normalized) + return roots + + +def round_test_command(state: dict[str, Any]) -> str: + """群と修正コミットの検証に使うコマンド(#880)。 + + `round_test` を持たない状態ファイル(変更の前の実行)は `baseline_test` を返す。 + 再開した実行の検証を、変更の前と同じにするためである。 + """ + command = (state.get("round_test") or {}).get("command") + if command: + return str(command) + return str((state.get("baseline_test") or {}).get("command") or "") + + +def _example_program(baseline_test: str, roots: list[str]) -> str: + """案内の例に使うプログラムの部分。起点があれば、最初の起点より前の語である。""" + try: + words = shlex.split(str(baseline_test or "")) + except ValueError: + return str(baseline_test or "") + for index, word in enumerate(words): + if roots and os.path.normpath(word) in roots: + return " ".join(words[:index]) + while len(words) > 1 and words[-1].startswith("-"): + words.pop() + return " ".join(words) + + +def round_test_hint( + round_test: Optional[str], baseline_test: str, + scope: Iterable[str], work: str, +) -> Optional[str]: + """`--round-test` を渡せば群ごとの検証が短くなるときに、案内の 1 行を返す(#880)。 + + 案内するのは、`--round-test` が無く、`--baseline-test` の探索の起点が無いか + `--scope` のテストの置き場所より広いときである。**止めない。** 全体を走らせても + 検証として誤りではなく、時間が掛かるだけである。 + """ + if round_test: + return None + locations = test_locations(list(scope), work) + if not locations: + return None + roots = baseline_search_roots(baseline_test, work) + if roots and all(covered_by_roots(root, locations) for root in roots): + return None + example = f"{_example_program(baseline_test, roots)} {' '.join(locations)}".strip() + return ( + "ℹ --baseline-test は --scope より広い範囲を走らせます。" + "群ごとの検証を短くするには --round-test に範囲のテストを渡します" + f"(例: {example})" + ) + + def scope_problem( - scope: Iterable[str], baseline_test: str, work: str + scope: Iterable[str], command: str, work: str, round_test: bool = False ) -> Optional[str]: - """関門に引っかかる理由を返す。問題が無ければ `None`。""" + """関門に引っかかる理由を返す。問題が無ければ `None`。 + + `round_test` が真なら、`command` を `--round-test` として読む(#880)。群の検証が + 走らせるのは範囲のテストであり、足したテストが入るべき実行集合はこちらである。 + """ listed = list(scope) locations = test_locations(listed, work) if not locations: @@ -144,23 +236,24 @@ def scope_problem( "含めないとその項目は必ず失敗します。" "例: --scope src/services tests/services" ) - roots = baseline_search_roots(baseline_test, work) + option = "--round-test" if round_test else "--baseline-test" + roots = (round_test_roots if round_test else baseline_search_roots)(command, work) outside = [loc for loc in locations if not covered_by_roots(loc, roots)] if outside: return ( f"--scope のテストの置き場所({', '.join(outside)})が " - f"--baseline-test の実行集合に入りません" + f"{option} の実行集合に入りません" f"(探索の起点: {', '.join(roots)})。" "足したテストが一度も実行されず、検証の判定に効きません。" - "--baseline-test の対象へ含めるか、--scope の置き場所を合わせてください" + f"{option} の対象へ含めるか、--scope の置き場所を合わせてください" ) return None def require_scope_covers_tests( - scope: Iterable[str], baseline_test: str, work: str + scope: Iterable[str], command: str, work: str, round_test: bool = False ) -> None: """関門を通す。通らなければ**中断する**(終了コード 4)。""" - problem = scope_problem(scope, baseline_test, work) + problem = scope_problem(scope, command, work, round_test=round_test) if problem: die(problem) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py index 4fe84471..697710ff 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py @@ -1131,3 +1131,32 @@ def test_a_missing_fix_result_reverts_the_commits_in_range( entry = read_state(state_path)["rounds"][0] assert entry["fix_base_sha"] == "AFTER_REVERT" assert entry["apply_rounds"][0]["failed_attempts"][0]["reverted"] == 2 + + +# ---------- 修正コミットの検証は範囲のテスト(#880 の AC2) ---------- + +def test_fix_commits_are_verified_with_the_round_test(patch_lib, refactor, gitfacts, cmd_converge, tmp_path, env_tmp_dir, monkeypatch): + """AC2 — 修正コミットごとに走るのは `round_test` だけで、全体テストは走らない。""" + real_collect = gitfacts.collect_commit_facts + state_path = _prepare_fix(patch_lib, refactor, tmp_path, env_tmp_dir, monkeypatch, ["PRRT_a"]) + state = read_state(state_path) + state["round_test"] = {"command": "pytest tests/services -q", "status": "green"} + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + + ran: list[str] = [] + patch_lib("collect_commit_facts", real_collect) + patch_lib("run_with_timeout", + lambda command, cwd, timeout, grace=5.0: ran.append(command) or (0, False)) + patch_lib("commit_trailers", lambda work, sha: _fix_commit()["trailers"]) + patch_lib("commit_diff_lines", lambda work, sha: 10) + patch_lib("commit_files", lambda work, sha: ["src/foo.py"]) + patch_lib("commit_touches_tests", lambda work, sha: False) + patch_lib("commit_test_changes", lambda work, sha: {}) + patch_lib("resolved_threads_on_github", lambda repo, pr: {"PRRT_a"}) + monkeypatch.setattr(gitfacts.subprocess, "run", + lambda cmd, **kw: subprocess.CompletedProcess(cmd, 0, "", "")) + + cmd_converge.cmd_merge_fix(type("A", (), {"id": 130, "round": 1})()) + + assert ran == ["pytest tests/services -q"] + assert "fix111" in read_state(state_path)["items"][0]["commits"] diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_final_gate.py b/plugins/ndf/skills/cross-refactoring/tests/test_final_gate.py index ee57bf85..ce76a28c 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_final_gate.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_final_gate.py @@ -281,3 +281,96 @@ def test_the_apply_round_verification_never_uses_the_ci( assert spy["tests"] == ["pytest -q"], "Step 5 は手元のテストを実行する" assert spy["gh"] == [], "Step 5 で継続的統合は読まない" + + +# ---------- 全体テストは最終ゲートで 1 回(#880 の AC3) ---------- + +ROUND_TEST = {"command": "pytest tests/services -q", "status": "green", + "checked_at": "2026-08-15T00:00:00"} + + +def test_a_standalone_run_with_a_round_test_runs_the_baseline_test_once( + refactor, cmd_gate, tmp_path, env_tmp_dir, spy, capsys +): + """AC3 — 単独起動でも、範囲のテストで検証してきたなら全体テストを 1 回通す。""" + state_path = _state(tmp_path, round_test=ROUND_TEST) + env_tmp_dir(state_path) + + cmd_gate.cmd_final_gate(_args()) + + assert spy["tests"] == ["pytest -q"] + assert "FINAL_GATE=cross-review" in capsys.readouterr().out + gate = read_state(state_path)["final_gate"] + assert gate["mode"] == "cross-review" + assert gate["checks"][-1]["status"] == "pass" + + +def test_a_workflow_step_run_with_a_round_test_runs_the_baseline_test_once( + refactor, cmd_gate, tmp_path, env_tmp_dir, spy, capsys +): + """AC3 — 工程として起動したときも、全体テストの呼び出しは 1 回。""" + state_path = _state(tmp_path, workflow_step=True, round_test=ROUND_TEST) + env_tmp_dir(state_path) + + cmd_gate.cmd_final_gate(_args()) + + assert spy["tests"] == ["pytest -q"] + assert "FINAL_GATE=passed" in capsys.readouterr().out + + +def test_a_standalone_run_whose_baseline_test_fails_enters_the_fix_round( + refactor, cmd_gate, tmp_path, env_tmp_dir, spy, capsys +): + """落ちれば `--workflow-step` と同じ修正ラウンドへ入る。cross-review へは渡さない。""" + state_path = _state(tmp_path, round_test=ROUND_TEST) + env_tmp_dir(state_path) + spy["test_code"] = 1 + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_final_gate(_args()) + + assert e.value.code == 2 + out = capsys.readouterr().out + assert "FINAL_GATE=failing" in out and "FINAL_GATE=cross-review" not in out + assert read_state(state_path)["final_gate"]["fix_rounds"] == 1 + + +def test_a_standalone_run_with_the_same_round_test_runs_no_test( + refactor, cmd_gate, tmp_path, env_tmp_dir, spy +): + """範囲のテストが全体テストと同じなら、群の検証が全体を見ている。""" + state_path = _state(tmp_path, round_test={"command": "pytest -q", "status": "green"}) + env_tmp_dir(state_path) + + cmd_gate.cmd_final_gate(_args()) + + assert spy["tests"] == [] + + +def test_a_standalone_run_with_a_ci_check_reads_the_ci_instead( + refactor, cmd_gate, tmp_path, env_tmp_dir, spy, capsys +): + """`--ci-check` があれば、手元の全体テストの代わりに継続的統合を見る。""" + state_path = _state(tmp_path, round_test=ROUND_TEST, ci_check="tests") + env_tmp_dir(state_path) + spy["gh_out"] = _check_runs(_run("tests")) + + cmd_gate.cmd_final_gate(_args()) + + assert spy["tests"] == [] + assert "FINAL_GATE=cross-review" in capsys.readouterr().out + + +def test_the_gate_check_records_the_command_and_seconds( + refactor, cmd_gate, tmp_path, env_tmp_dir, spy +): + """最終ゲートの記録は、実行したコマンドと所要の秒数を持つ。""" + state_path = _state(tmp_path, workflow_step=True, round_test=ROUND_TEST) + env_tmp_dir(state_path) + + cmd_gate.cmd_final_gate(_args()) + + check = read_state(state_path)["final_gate"]["checks"][-1] + assert check["mode"] == "test" + assert check["command"] == "pytest -q" + assert isinstance(check["seconds"], (int, float)) and check["seconds"] >= 0 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index aac848e0..3319690f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -747,3 +747,95 @@ def test_a_failed_rebuild_leaves_the_state_untouched(run_init, tmp_path): probe={"claude": "x", "codex": "y"}) assert e.value.code == refactor_abort() assert path.read_text(encoding="utf-8") == before + + +# ---------- 範囲のテスト `--round-test`(#880 の AC1・AC4・AC5) ---------- + +@pytest.fixture +def test_calls(patch_lib): + """`init` が実行したテストのコマンドを記録する。終了コードはコマンドごとに決める。""" + seen: list[str] = [] + codes: dict[str, int] = {} + + def fake_run(command, cwd, timeout, grace=5.0): + seen.append(command) + return codes.get(command, 0), False + + patch_lib("run_with_timeout", fake_run) + seen_codes = codes + return type("Calls", (), {"seen": seen, "codes": seen_codes})() + + +def test_the_round_test_is_parsed_and_unset_by_default(patch_lib, refactor, monkeypatch): + assert _parsed_init_args(patch_lib, refactor, monkeypatch)["round_test"] is None + captured = _parsed_init_args( + patch_lib, refactor, monkeypatch, "--round-test", "pytest tests -q") + assert captured["round_test"] == "pytest tests -q" + + +def test_init_records_the_round_test(run_init, tmp_path, test_calls): + """AC1 — `--round-test` は状態の `round_test.command` に残る。""" + run_init(_args(tmp_path, round_test="pytest -q -k scope", baseline_test="true")) + _, state = _state_of(tmp_path) + assert state["round_test"]["command"] == "pytest -q -k scope" + assert state["round_test"]["status"] == "green" + assert state["baseline_test"]["command"] == "true" + assert test_calls.seen == ["true", "pytest -q -k scope"], "全体テストの後に範囲のテストを 1 回" + + +def test_an_omitted_round_test_is_the_baseline_test_and_runs_once(run_init, tmp_path, test_calls): + """AC1 — 省けば `baseline_test` と同じコマンドで、テストの実行は 1 回。""" + run_init(_args(tmp_path, baseline_test="true")) + _, state = _state_of(tmp_path) + assert state["round_test"]["command"] == state["baseline_test"]["command"] == "true" + assert state["round_test"]["status"] == "green" + assert test_calls.seen == ["true"] + + +def test_a_round_test_equal_to_the_baseline_test_runs_once(run_init, tmp_path, test_calls): + run_init(_args(tmp_path, round_test="true", baseline_test="true")) + _, state = _state_of(tmp_path) + assert state["round_test"]["command"] == "true" + assert test_calls.seen == ["true"] + + +@pytest.mark.parametrize("command", ["false", "exit 5"]) +def test_init_stops_when_the_round_test_fails(run_init, tmp_path, command): + """AC4 — 範囲のテストが成功しなければ止める。集まらない終了コード 5 も失敗。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, round_test=command, baseline_test="true")) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + + +def test_init_stops_when_the_round_test_runs_outside_the_scope_tests(run_init, tmp_path, test_calls): + """AC4 — `--scope` のテストの置き場所が `--round-test` の実行集合の外なら止める。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, scope=["src", "tests"], + round_test="pytest src", baseline_test="true")) + assert e.value.code == refactor_abort() + assert test_calls.seen == [], "関門はテストの実行より先" + + +def test_init_hints_the_round_test_when_the_baseline_test_is_broad(run_init, tmp_path, capsys): + """AC5 — `--round-test` が無く全体を走らせる `--baseline-test` なら、案内して続ける。""" + run_init(_args(tmp_path, baseline_test="true")) + assert "--round-test" in capsys.readouterr().err + assert _state_path(tmp_path).exists() + + +def test_init_does_not_hint_when_the_round_test_is_given(run_init, tmp_path, capsys): + run_init(_args(tmp_path, round_test="true", baseline_test="true")) + assert "--round-test" not in capsys.readouterr().err + + +def test_resume_notifies_a_changed_round_test(run_init, tmp_path, capsys): + """再開では `--round-test` を反映せず、違えば知らせる。""" + run_init(_args(tmp_path, round_test="true")) + _, before = _state_of(tmp_path) + capsys.readouterr() + + run_init(_args(tmp_path, round_test="pytest -q")) + _, after = _state_of(tmp_path) + assert "ℹ --round-test は再開では反映しません" in capsys.readouterr().err + assert after["round_test"] == before["round_test"] diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py b/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py index d0e0fb17..573f5d94 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py @@ -179,3 +179,98 @@ def test_the_matched_location_is_checked_against_the_search_roots(scope, tmp_pat ["skills/development-workflow"], "pytest other", str(tmp_path)) assert problem is not None assert "skills/development-workflow/tests" in problem + + +# ---------- `--round-test` の実行集合(#880 の AC4) ---------- + +def _services(tmp_path): + (tmp_path / "tests" / "services").mkdir(parents=True) + (tmp_path / "tests" / "services" / "test_one.py").write_text("", encoding="utf-8") + (tmp_path / "scripts").mkdir() + (tmp_path / "scripts" / "run-scope-tests.sh").write_text("", encoding="utf-8") + (tmp_path / "scripts" / "run_tests.py").write_text("", encoding="utf-8") + + +def test_a_test_file_is_a_round_test_root(scope, tmp_path): + _services(tmp_path) + assert scope.round_test_roots( + "pytest tests/services/test_one.py", str(tmp_path)) == [ + "tests/services/test_one.py"] + + +def test_an_option_value_and_the_work_root_are_not_round_test_roots(scope, tmp_path): + """`--project .` の `.` はオプションの値で、作業ディレクトリの根でもある。""" + _services(tmp_path) + assert scope.round_test_roots( + "uv run --project . pytest tests/services/test_one.py", str(tmp_path)) == [ + "tests/services/test_one.py"] + assert scope.round_test_roots("pytest . -q", str(tmp_path)) == [] + + +@pytest.mark.parametrize("command", [ + "bash scripts/run-scope-tests.sh", "python scripts/run_tests.py", +]) +def test_a_wrapper_script_is_not_a_round_test_root(scope, tmp_path, command): + _services(tmp_path) + assert scope.round_test_roots(command, str(tmp_path)) == [] + + +def test_a_directory_is_a_round_test_root(scope, tmp_path): + _services(tmp_path) + assert scope.round_test_roots( + "uv run --with pytest pytest tests/services -q", str(tmp_path)) == [ + "tests/services"] + + +@pytest.mark.parametrize("command", [ + "pytest tests/services/test_one.py", + "uv run --project . pytest tests/services/test_one.py", + "pytest scripts", +]) +def test_a_round_test_narrower_than_the_scope_tests_stops(refactor_lib, scope, tmp_path, command): + """AC4 — 置き場所それぞれについて、起点のどれかが同じか祖先でなければ止める。""" + _services(tmp_path) + problem = scope.scope_problem( + ["src", "tests/services"], command, str(tmp_path), round_test=True) + assert problem is not None and "--round-test" in problem + with pytest.raises(SystemExit) as e: + scope.require_scope_covers_tests( + ["src", "tests/services"], command, str(tmp_path), round_test=True) + assert e.value.code == refactor_lib.ABORT + + +@pytest.mark.parametrize("command", [ + "bash scripts/run-scope-tests.sh", + "pytest -q", + "pytest tests/services", + "pytest tests", +]) +def test_a_round_test_covering_the_scope_tests_passes(scope, tmp_path, command): + """起点の無いコマンドは全体を覆うとみなす。""" + _services(tmp_path) + assert scope.scope_problem( + ["src", "tests/services"], command, str(tmp_path), round_test=True) is None + + +# ---------- `--round-test` の案内(#880 の AC5) ---------- + +@pytest.mark.parametrize("baseline, expected", [ + ("pytest -q", True), + ("pytest tests", True), + ("pytest tests/services", False), +]) +def test_the_round_test_hint(scope, tmp_path, baseline, expected): + """起点が無いか範囲より広いときだけ、1 行の案内を返す。""" + _services(tmp_path) + hint = scope.round_test_hint( + None, baseline, ["src", "tests/services"], str(tmp_path)) + assert (hint is not None) is expected + if expected: + assert "--round-test" in hint and "\n" not in hint + + +def test_no_round_test_hint_when_the_round_test_is_given(scope, tmp_path): + _services(tmp_path) + assert scope.round_test_hint( + "pytest tests/services", "pytest -q", ["src", "tests/services"], + str(tmp_path)) is None diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_verify_round.py b/plugins/ndf/skills/cross-refactoring/tests/test_verify_round.py index 23b9ee29..b1580f18 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_verify_round.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_verify_round.py @@ -240,3 +240,50 @@ def test_a_failing_verification_still_names_the_plan(patch_lib, refactor, cmd_co cmd_converge.cmd_verify_round(_args()) assert url in capsys.readouterr().err + + +# ---------- 範囲のテスト `--round-test`(#880 の AC2・AC6・AC7) ---------- + +ROUND_TEST = {"command": "pytest tests/services -q", "status": "green", + "checked_at": "2026-08-15T00:00:00"} + + +def test_the_round_is_verified_with_the_round_test(patch_lib, refactor, cmd_converge, tmp_path, env_tmp_dir, monkeypatch): + """AC2 — 群の検証は `round_test` だけを実行する。全体テストは実行しない。""" + state_path = _state(tmp_path, round_test=ROUND_TEST) + env_tmp_dir(state_path) + seen = _test_run(patch_lib, refactor, monkeypatch, code=0) + + cmd_converge.cmd_verify_round(_args()) + + assert [c for c, _, _ in seen] == ["pytest tests/services -q"] + record = read_state(state_path)["rounds"][0]["verifications"][-1] + assert record["command"] == "pytest tests/services -q" + + +@pytest.mark.parametrize("code", [0, 1]) +def test_the_verification_records_its_seconds(patch_lib, refactor, cmd_converge, tmp_path, env_tmp_dir, monkeypatch, code): + """AC6 — 検証の記録は所要の秒数を持つ。""" + state_path = _state(tmp_path, round_test=ROUND_TEST) + env_tmp_dir(state_path) + _test_run(patch_lib, refactor, monkeypatch, code=code) + + try: + cmd_converge.cmd_verify_round(_args()) + except SystemExit: + pass + + seconds = read_state(state_path)["rounds"][0]["verifications"][-1]["seconds"] + assert isinstance(seconds, (int, float)) and seconds >= 0 + + +def test_a_state_without_the_round_test_verifies_with_the_baseline_test(patch_lib, refactor, cmd_converge, tmp_path, env_tmp_dir, monkeypatch): + """AC7 — 変更の前の状態ファイルを再開したときは、`baseline_test` で検証する。""" + state_path = _state(tmp_path) + assert "round_test" not in read_state(state_path) + env_tmp_dir(state_path) + seen = _test_run(patch_lib, refactor, monkeypatch, code=0) + + cmd_converge.cmd_verify_round(_args()) + + assert [c for c, _, _ in seen] == ["pytest -q"] From f790d8992d26a3c868c3aa4463bf31d89f0f2a64 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 13:08:42 +0000 Subject: [PATCH 05/19] =?UTF-8?q?Docs:=20#880=20#494=20--round-test=20?= =?UTF-8?q?=E3=81=A8=20assess=20=E3=81=AE=E4=BD=BF=E3=81=84=E6=96=B9?= =?UTF-8?q?=E3=82=92=E6=9B=B8=E3=81=8D=E3=80=81=E9=A3=9B=E3=81=B0=E3=81=99?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E3=81=A8=E9=80=80=E9=81=BF=E3=81=99=E3=82=8B?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E3=82=92=E5=88=86=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cross-refactoring の SKILL.md: 引数の表に --round-test、検証の単位を範囲のテストへ、 前提に assess(終了コード 3 なら起動しない) - CLAUDE.md の cross-refactoring の節: 起動の例に --round-test、全体テストが 2 回に寄ること - workflow-modes.md「構造改善の退避先」: 飛ばす(assess の終了コード 3)と refactoring 単独へ 退避する(今の 3 条件)を分け、飛ばしたときの記録の残し方を書く - stage-notes.md: 構造改善に入ったら assess → --round-test に範囲のテスト → --baseline-test に全体テスト、の順を書く Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- CLAUDE.md | 3 +- plugins/ndf/skills/cross-refactoring/SKILL.md | 21 ++++++++++--- .../references/stage-notes.md | 9 ++++++ .../references/workflow-modes.md | 31 +++++++++++++++++-- 4 files changed, 56 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 77ad1100..fbf3effd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,12 +62,13 @@ python3 plugins/ndf/scripts/instructions-check.py --root . `/ndf:cross-refactoring` は参加者に構造改善を提案させ、同じ参加者から輪番で選んだ 1 者が適用する。新しい提案が出なくなるまで繰り返す。参加者の既定は **codex / kiro とホスト(ホストが codex / kiro なら 2 者)** で、`--exclude` / `--include` で名指しで変える(agy は `--include agy` で戻す)。レビューは最終ゲートの `cross-review` が担う。 ```bash -/ndf:cross-refactoring 130 --scope src/services --baseline-test "pytest -q" +/ndf:cross-refactoring 130 --scope src/services tests/services --round-test "pytest tests/services -q" --baseline-test "pytest -q" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=claude-opus-5 /ndf:cross-refactoring 130 --scope src --include agy --exclude kiro ``` - `--scope` は必須。提案が発散して PR が肥大するのを防ぐ。**検証にも効く**ので、現状固定テストの置き場所も含める +- v10.17.3 の次の版で、群ごとの検証は `--round-test`(範囲のテスト)で走らせ、`--baseline-test`(全体のテスト)は着手前と最終ゲートの 2 回だけ走らせる。`--round-test` を省くと全体のテストが群ごとに走る - ホストと同じランタイムが適用担当になる場合も、サブエージェントではなく **CLI プロセス**として起動する - モデルを比べるなら `--model <ランタイム>=` を参加者の全員に指定する。実際に動いたモデルを取得できるのは claude だけで、残りは指定値で代用する。指定が無いラウンドは集計から分離される - 適用担当は参加者の数のラウンドで 1 周する。輪番は適用ラウンドごとに進むため、`--max-outer-rounds`(既定 3)が切る提案の回数とは対応しない diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index 6bf574fc..826439c8 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -1,7 +1,7 @@ --- name: cross-refactoring description: "Let several CLIs propose, apply, and review refactorings on a PR until no new proposal appears. Use when structural improvement should converge across runtimes(クロスリファクタリング・多AIリファクタリング・収束リファクタリング)." -argument-hint: "[PR番号] --scope PATH... [--host claude|codex|agy|kiro] [--exclude NAMES] [--include NAMES] [--require-all] [--model RT=MODEL] [--baseline-test CMD] [--max-test-rounds N] [--max-outer-rounds N] [--max-fix-rounds N] [--max-items-per-round N] [--ci-check NAME] [--workflow-step]" +argument-hint: "[PR番号] --scope PATH... [--host claude|codex|agy|kiro] [--exclude NAMES] [--include NAMES] [--require-all] [--model RT=MODEL] [--baseline-test CMD] [--round-test CMD] [--max-test-rounds N] [--max-outer-rounds N] [--max-fix-rounds N] [--max-items-per-round N] [--ci-check NAME] [--workflow-step]" allowed-tools: - Bash - Read @@ -63,7 +63,7 @@ allowed-tools: | --- | --- | | 参加者 | **全員 CLI プロセス。** ホストのサブエージェント機能は使わない。ホストと同じランタイムが実装担当のラウンドでも別プロセスで起動する | | 参加者 | **提案と適用を同じ参加者で回す。** 既定は codex / kiro とホストで、`--exclude` / `--include` で名指しで変える。確認を通らない者は外して続ける | -| 検証の単位 | **適用ラウンド(群)に対して 1 回。** 判定は `--baseline-test` の合否で決まり、レビュー CLI は起動しない | +| 検証の単位 | **適用ラウンド(群)に対して 1 回。** 判定は `--round-test`(範囲のテスト)の合否で決まり、レビュー CLI は起動しない。全体のテスト(`--baseline-test`)は着手前と最終ゲートの 2 回だけ走る | | 収束しない項目 | **捨てる。** リファクタリングは任意の作業なので、揉める提案を Pull Request に残さない | | コミットの単位 | **1 適用ラウンド = 1 コミット。** テストも適用ラウンドの単位で 1 回だけ求める | | 改修計画 | **Pull Request のコメント 1 件へ残す。** 理由と手順は提案の時点でしか残らない。ラウンドが進むたびに同じコメントを編集する。URL は永続で、マージの後も開ける。`--plan-file` を明示したときだけファイルにする | @@ -91,7 +91,8 @@ allowed-tools: | `--include NAMES` | 参加者に足す者(例: `--include agy`)。再開で `none` を渡すと空へ戻す | なし | | `--require-all` | 確認を通らない者が 1 者でもいれば中断する(終了コード 4)。付けなければ外して続ける | 外して続ける | | `--model RT=MODEL` | ランタイムごとのモデル。繰り返し指定できる | CLI の既定 | -| `--baseline-test CMD` | 着手前と各コミットで実行するテスト。**振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須** | 必須 | +| `--baseline-test CMD` | 着手前と最終ゲートで実行する全体のテスト。**振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須** | 必須 | +| `--round-test CMD` | 着手前・群の検証・修正のコミットごとに実行する範囲のテスト。`--scope` のテストの置き場所を覆わなければ `init` が止まる。省くと `--baseline-test` が群ごとに走る | `--baseline-test` と同じ | | `--max-test-rounds N` | **テスト整備ラウンド**の上限。到達したら採用が残っていても提案ラウンドへ進む | `2` | | `--max-outer-rounds N` | **提案ラウンド**の上限。切るのは提案の回数であって、適用できる件数ではない | `3` | | `--max-fix-rounds N` | **1 つの適用ラウンドあたり**の修正ラウンドの上限 | `3` | @@ -104,7 +105,7 @@ allowed-tools: | `--plan-file PATH` | 改修計画を**ファイル**へ書き出す先(**対象リポジトリからの相対パス**)。空文字を渡すと記録しない | Pull Request のコメント 1 件 | ```text -/ndf:cross-refactoring 130 --scope src/services tests/services --baseline-test "pytest -q" +/ndf:cross-refactoring 130 --scope src/services tests/services --round-test "pytest tests/services -q" --baseline-test "pytest -q" /ndf:cross-refactoring 130 --scope src --baseline-test "pytest -q" --sync-command "make generate" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=claude-opus-5 /ndf:cross-refactoring 130 --scope src --host codex --max-outer-rounds 1 @@ -175,6 +176,15 @@ allowed-tools: - 対象の Pull Request が Draft で開いている(未作成なら `/ndf:pr` で先に作る) +- 本番コードの差分がある。起動の前に `assess` で飛ばしてよいかを見る。**終了コード 3 なら + 起動しない**(本番コードの差分が無いか、変更が `--max-lines`(既定 10)行以下)。2 は + 判定できなかったことを示し、飛ばしてよいとは読まない。飛ばしたときの記録の残し方は + `development-workflow` の `references/workflow-modes.md`「構造改善の退避先」にある + + ```bash + python3 "$SCRIPTS/refactor.py" assess --base origin/develop; echo "exit=$?" + ``` + ## 全体フロー **ラウンドは 4 層である。** テスト整備ラウンドと提案ラウンドは、集める提案の中身が @@ -293,7 +303,8 @@ rf_eval() { } rf_eval init "$PR" --scope $SCOPE \ - --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ + --baseline-test "$BASELINE" ${ROUND_TEST:+--round-test "$ROUND_TEST"} \ + ${HOST:+--host "$HOST"} \ ${EXCLUDE:+--exclude "$EXCLUDE"} ${INCLUDE:+--include "$INCLUDE"} \ ${REQUIRE_ALL:+--require-all} \ --max-test-rounds "$MAX_TEST" --max-outer-rounds "$MAX_OUTER" \ diff --git a/plugins/ndf/skills/development-workflow/references/stage-notes.md b/plugins/ndf/skills/development-workflow/references/stage-notes.md index eba4d957..fe123459 100644 --- a/plugins/ndf/skills/development-workflow/references/stage-notes.md +++ b/plugins/ndf/skills/development-workflow/references/stage-notes.md @@ -110,6 +110,15 @@ 退避先は [workflow-modes.md](workflow-modes.md)「構造改善の退避先」にある。 +**工程に入ったら、次の順で進める。** 全体テストを群ごとに回すと、1 回の実行が長くなり +構造改善を通す費用が見合わなくなる。 + +| 順 | 手 | +| ---: | --- | +| 1 | `refactor.py assess --base <起点のブランチ>` を実行する。終了コード 3 なら飛ばす(記録の残し方は「構造改善の退避先」) | +| 2 | `--round-test` に **`--scope` のテストの置き場所だけを走らせるコマンド**を渡す(例: `uv run --with pytest pytest <範囲のテストの置き場所> -q`)。群の検証と修正のコミットの検証はこれで走る | +| 3 | `--baseline-test` に**全体テスト**を渡す。全体テストは `init` と最終ゲートで 1 回ずつ走る。`quality-gates` の全体テストと重ねて回さない | + **この工程で `cross-refactoring` を通したら、次の「実装レビュー」を重ねて呼ばない。** その Skill は 最後に Pull Request 全体を `cross-review` にかけるため、実装レビューの工程が求めるものをそこで 満たす。構造改善の後に実装の差分が増えた場合だけ、改めて呼ぶ。 diff --git a/plugins/ndf/skills/development-workflow/references/workflow-modes.md b/plugins/ndf/skills/development-workflow/references/workflow-modes.md index d00ffcef..61f88895 100644 --- a/plugins/ndf/skills/development-workflow/references/workflow-modes.md +++ b/plugins/ndf/skills/development-workflow/references/workflow-modes.md @@ -194,8 +194,35 @@ ## 構造改善の退避先 工程表は `standard` と `legacy-refactor` の構造改善に `cross-refactoring` を置く。 -`light` と `operation` は工程そのものが対象外である。 -**次のいずれかに当たるときは `refactoring` 単独で進める。** +`light` と `operation` は工程そのものが対象外である。**工程に入ったら、最初に +`refactor.py assess` で飛ばしてよいかを見る。** 飛ばす条件と、`refactoring` 単独へ退避する +条件は別である。 + +```bash +python3 "$RF/refactor.py" assess --base origin/develop; rc=$?; echo "exit=$rc" +``` + +`$RF` は `cross-refactoring` の `scripts/` の絶対パスである。 + +**飛ばす。** 次に当たるときは何も起動しない。 + +| 条件 | 理由 | +| --- | --- | +| `assess` が終了コード 3(本番コードの差分が無い、または変更が 10 行以下) | 整える対象が無いか、提案の費用が見合わない | + +`assess` が終了コード 2(引数の誤り・起点を解けない)を返したときは、値を直して打ち直す。 +**判定できないことを、飛ばしてよいと読まない。** + +飛ばしたときは次の 3 つを残す。**工程の記録は通した場合と同じ値にする。** 区別は Pull Request の +本文の 1 行が持つ。記録を打たずに進めると「記録なし」のまま残り、飛ばした場合と見分けられる。 + +| 記録先 | 何を残すか | +| --- | --- | +| 進行の記録 | 工程「構造改善」(`projects-sync.sh <課題番号> stage "構造改善"`) | +| Pull Request の本文 | `構造改善: 飛ばした()` の 1 行 | +| 持ち場の報告 | 同じ 1 行を `理由` に含める | + +**`refactoring` 単独へ退避する。** `assess` が終了コード 0 で、次のいずれかに当たるとき。 | 条件 | 理由 | | --- | --- | From bce8a304cd0f32f1ab03e3e8fe9d344eba966541 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 13:12:01 +0000 Subject: [PATCH 06/19] =?UTF-8?q?Update:=20#723=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E6=95=B4=E5=82=99=E3=81=A8=E9=81=A9=E7=94=A8=E3=81=AE?= =?UTF-8?q?=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=E3=81=8C=20.md=20=E3=81=AE?= =?UTF-8?q?=E6=96=87=E8=A8=80=E3=82=92=E5=9B=BA=E5=AE=9A=E3=81=99=E3=82=8B?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E6=8E=A1=E3=82=89=E3=81=AA?= =?UTF-8?q?=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - merge_test_proposals: target の # より前が .md で終わる提案を「文書の文言を固定するテストは足さない」で見送る(F8・AC17) - verify.py に doc_wording_tests を新設し、verify_apply_round の verify_test_changes の直後で呼ぶ(F9・AC18) - 追加行の文字列リテラルが、追跡している .md のパスか / で揃えた末尾に一致すれば当たり - テストのファイルの直下の定数と、同じディレクトリの補助モジュールから import した定数も追う(補助モジュールは群で触っていなければ git から読む) - gitfacts.py に tracked_markdown(git ls-files -z '*.md')を足し、apply.py が群ごとに 1 回読んで渡す Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- .../scripts/refactor_lib/commands/apply.py | 10 +- .../scripts/refactor_lib/gitfacts.py | 10 ++ .../scripts/refactor_lib/proposals.py | 4 + .../scripts/refactor_lib/verify.py | 152 ++++++++++++++++++ .../tests/test_doc_wording_tests.py | 147 +++++++++++++++++ .../tests/test_test_rounds.py | 21 +++ 6 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_doc_wording_tests.py diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index f18a57c4..8cfa299d 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -30,6 +30,7 @@ safe_int, collect_commit_facts, commits_in_range, + tracked_markdown, ) from ..intake import ( IntakeScope, @@ -884,6 +885,7 @@ def _collect_apply_group_facts( def _determine_apply_problem( ctx: _ApplyExecutionContext, + work: pathlib.Path, items: list[dict[str, Any]], missing: list[str], facts: list[dict[str, Any]], @@ -895,7 +897,11 @@ def _determine_apply_problem( "(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します)" ) scope = ctx.state.get("target_scope") or [] - return verify_apply_round(items, facts, scope) + # **追跡している `.md` の一覧は群ごとに 1 回だけ読む**(#723)。 + return verify_apply_round( + items, facts, scope, + work=str(work), tracked_md=tracked_markdown(str(work)), + ) def _record_apply_group_outcome( @@ -942,7 +948,7 @@ def _verify_apply_group( """ items = [find_item(ctx.state, i) for i in ctx.group["items"]] missing, shas, facts = _collect_apply_group_facts(ctx, commit_range, reported) - problem = _determine_apply_problem(ctx, items, missing, facts) + problem = _determine_apply_problem(ctx, commit_range.work, items, missing, facts) _record_apply_group_outcome(ctx, items, shas, facts, problem) if problem: return [], list(ctx.group["items"]) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index ad74328f..427da7d6 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -188,6 +188,16 @@ def commit_test_changes(work: str, sha: str) -> dict[str, tuple[list[str], list[ return changes +def tracked_markdown(work: str) -> list[str]: + """追跡している `.md` のリポジトリ相対パス(#723)。 + + `-z` で読む。既定の出力は ASCII 以外を含むパスを引用符と 8 進数で書き換える。 + パターン `*.md` は `/` をまたいで一致し、下の階層の `.md` も拾う。 + """ + out = git_out(work, ["ls-files", "-z", "*.md"], strip=False) + return [p for p in (out or "").split("\0") if p] + + def is_test_path(path: str) -> bool: """テストの置き場所か。判定は `commit_touches_tests` と同じ印で行う。""" lowered = f"/{path.lower()}" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py index 6752482a..6c8cf95e 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py @@ -306,6 +306,10 @@ def reject(item: dict[str, Any]) -> Optional[str]: "語彙外の値を含むため対象外" "(`case` と `level` は列挙した識別子のいずれかで書く)" ) + # **文書の文言を固定するテストは採らない**(#723)。文書の振る舞いは文言 + # そのものであり、固定すると後の文書の整理が振る舞いの変更として取り消される。 + if item["target"].split("#", 1)[0].endswith(".md"): + return "文書の文言を固定するテストは足さない" return None return _select( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py index db5b9b10..2a466667 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py @@ -5,6 +5,9 @@ """ from __future__ import annotations +import ast +import difflib +import posixpath import re from collections import Counter @@ -242,6 +245,8 @@ def _verify_apply_commit_count(facts: list[dict[str, Any]]) -> Optional[str]: def verify_apply_round( items: list[dict[str, Any]], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, + work: Optional[str] = None, + tracked_md: Iterable[str] = (), ) -> Optional[str]: """適用ラウンド 1 つ分の適用結果を検証する。問題があれば失敗理由を返す。 @@ -253,6 +258,9 @@ def verify_apply_round( `facts` は `collect_commit_facts()` が git から作る。振る舞い不変そのものは ここでは確かめない(テストは `verify-round` が実行する)が、**手順が守られたかは 結果から確かめられる**。 + + `tracked_md` は追跡している `.md` の一覧(`tracked_markdown()`)、`work` は + 補助モジュールを git から読む作業ディレクトリである(`doc_wording_tests`)。 """ if not facts: return ( @@ -283,6 +291,14 @@ def verify_apply_round( if problem: return problem + # **文書の文言を固定するテストを足していないか**(#723)。 + hits = doc_wording_tests(facts, tracked_md, work) + if hits: + return ( + "文書の文言を固定するテストは足さない" + f"({'、'.join(f'{path}: {literal}' for path, literal in hits)})" + ) + problem = _verify_diff_budget(items, facts) if problem: return problem @@ -527,3 +543,139 @@ def apply_judgements_to_group( ) record_pending_judgements(entry, group, remaining) return remaining + + +# ---------- 文書の文言を固定するテスト(#723) ---------- +# +# **判定は追跡している `.md` のパスとの一致で行う。** `.md` で終わる文字列をすべて弾くと、 +# 一時ファイルの `.md` を入力に渡す検査スクリプトのテストまで弾く。追跡している `.md` と +# 同じ名前の一時ファイル(`README.md` など)を使うテストは当たるが、そのときも群が +# 取り消されるだけで、Pull Request に文言固定テストが残る側には倒れない(決定 9)。 +# +# **動的に組み立てたパス(`glob` の結果など)は追わない。** 提案の基準とレビューが見る。 + +_STRING = re.compile(r"""[rRbBuUfF]{0,2}(["'])((?:\\.|(?!\1)[^\\])*)\1""") + + +def _names_markdown(literal: str, tracked: Iterable[str]) -> bool: + """文字列が追跡している `.md` のパスか、`/` の区切りで揃えたその末尾に一致するか。""" + if not literal.endswith(".md"): + return False + return any(p == literal or p.endswith("/" + literal) for p in tracked) + + +def _markdown_literals(line: str, tracked: list[str]) -> list[str]: + return [m.group(2) for m in _STRING.finditer(line) + if _names_markdown(m.group(2), tracked)] + + +def _added_lines(before: list[str], after: list[str]) -> list[str]: + """変更の後にだけある行。置き換えた行も追加として数える。""" + matcher = difflib.SequenceMatcher(None, before, after, autojunk=False) + return [line for tag, _, _, j1, j2 in matcher.get_opcodes() + if tag in {"insert", "replace"} for line in after[j1:j2]] + + +def _parse(source: str) -> Optional[ast.Module]: + try: + return ast.parse(source) + except (SyntaxError, ValueError): + return None + + +def _markdown_constants(tree: ast.Module, tracked: list[str]) -> dict[str, str]: + """モジュールの直下の代入のうち、右辺が追跡している `.md` を指す名前と、その文字列。""" + found: dict[str, str] = {} + for node in tree.body: + if isinstance(node, ast.Assign): + targets, value = node.targets, node.value + elif isinstance(node, ast.AnnAssign) and node.value is not None: + targets, value = [node.target], node.value + else: + continue + literal = next(( + c.value for c in ast.walk(value) + if isinstance(c, ast.Constant) and isinstance(c.value, str) + and _names_markdown(c.value, tracked) + ), None) + if literal is None: + continue + for target in targets: + if isinstance(target, ast.Name): + found[target.id] = literal + return found + + +def _helper_source( + helper: str, changes: dict[str, tuple[list[str], list[str]]], + work: Optional[str], sha: Optional[str], +) -> Optional[str]: + """補助モジュールの変更の後の内容。群で触っていなければ git から読む。""" + if helper in changes: + return "".join(changes[helper][1]) + if not work or not sha: + return None + return git_out(work, ["show", f"{sha}:{helper}"], strip=False) + + +def _imported_constants( + tree: ast.Module, test_path: str, tracked: list[str], + changes: dict[str, tuple[list[str], list[str]]], + work: Optional[str], sha: Optional[str], +) -> dict[str, str]: + """同じディレクトリの補助モジュールから import した、`.md` を指す定数。""" + found: dict[str, str] = {} + folder = posixpath.dirname(test_path) + for node in tree.body: + if (not isinstance(node, ast.ImportFrom) or not node.module + or "." in node.module or node.level > 1): + continue + source = _helper_source( + posixpath.join(folder, f"{node.module}.py"), changes, work, sha) + helper_tree = _parse(source) if source else None + if helper_tree is None: + continue + constants = _markdown_constants(helper_tree, tracked) + for alias in node.names: + if alias.name in constants: + found[alias.asname or alias.name] = constants[alias.name] + return found + + +def _last_sha(facts: Iterable[dict[str, Any]]) -> Optional[str]: + shas = [c.get("sha") for c in facts if c.get("exists", True) and c.get("sha")] + return shas[-1] if shas else None + + +def doc_wording_tests( + facts: Iterable[dict[str, Any]], tracked_md: Iterable[str], + work: Optional[str] = None, +) -> list[tuple[str, str]]: + """追加したテストの行が、追跡している `.md` を指していれば `(ファイル, 文字列)` を返す。 + + 当たりは、追加行の文字列リテラルと、テストのファイルの直下の定数・同じディレクトリの + 補助モジュールから import した定数のうち `.md` を指すものを、追加行が識別子として + 使う場合である。補助モジュールを群で触っていなければ、`work` の git から読む。 + """ + facts = list(facts) + tracked = list(tracked_md) + if not tracked: + return [] + changes = collect_test_changes(facts) + sha = _last_sha(facts) + hits: list[tuple[str, str]] = [] + for path, (before, after) in sorted(changes.items()): + added = _added_lines(before, after) + found = {lit for line in added for lit in _markdown_literals(line, tracked)} + tree = _parse("".join(after)) if path.endswith(".py") else None + if tree is not None: + names = { + **_markdown_constants(tree, tracked), + **_imported_constants(tree, path, tracked, changes, work, sha), + } + found.update( + literal for name, literal in names.items() + if any(re.search(rf"\b{re.escape(name)}\b", line) for line in added) + ) + hits.extend((path, literal) for literal in sorted(found)) + return hits diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_doc_wording_tests.py b/plugins/ndf/skills/cross-refactoring/tests/test_doc_wording_tests.py new file mode 100644 index 00000000..53f447e6 --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_doc_wording_tests.py @@ -0,0 +1,147 @@ +"""適用ラウンドの検証が、`.md` の文言を固定するテストの追加を弾くことのテスト(#723)。 + +**提案の基準だけでは、実装担当が書いたテストに混じったときに止まらない。** 取り込みで +`target` が `.md` の提案を見送るのに加えて、追加したテストの行が**追跡している `.md`** +を指していれば群を取り消す。`.md` で終わる文字列をすべて弾く形は採らない +(一時ファイルの `.md` を入力に渡すテストは残す)。 +""" +from __future__ import annotations + +import subprocess + +import pytest + +TRACKED = ["plugins/ndf/skills/x/SKILL.md", "README.md"] +REASON = "文書の文言を固定するテストは足さない" + + +def _change(before: list[str], added: list[str]) -> tuple[list[str], list[str]]: + """`test_changes` の 1 件(変更前後の行)。`added` を末尾へ足した形にする。""" + return before, before + added + + +def _fact(changes: dict, sha: str = "abc1234") -> dict: + return { + "sha": sha, "exists": True, "test_status": "pass", "touches_tests": True, + "diff_lines": 3, "files": sorted(changes), + "trailers": {"Item-Id": "T1-001", "Round": "1", + "Impl-Runtime": "codex", "Impl-Model": "gpt-5.5"}, + "test_changes": changes, + } + + +def _hit_files(verify, facts, work=None) -> list[str]: + return sorted({path for path, _ in verify.doc_wording_tests(facts, TRACKED, work)}) + + +# ---------- 追加行のリテラル ---------- + +def test_only_literals_naming_a_tracked_markdown_hit(verify): + """追跡している `.md` のパス・その末尾が一致するものだけが当たる。""" + facts = [_fact({ + "tests/test_full.py": _change([], [' p = ROOT / "plugins/ndf/skills/x/SKILL.md"\n']), + "tests/test_tail.py": _change([], [' p = ROOT / "x/SKILL.md"\n']), + "tests/test_tmp.py": _change([], [' p = tmp_path / "a.md"\n']), + })] + assert _hit_files(verify, facts) == ["tests/test_full.py", "tests/test_tail.py"] + + +def test_a_literal_only_in_unchanged_lines_does_not_hit(verify): + """見るのは追加行だけ。既存の行の文字列は、この群が足したものではない。""" + facts = [_fact({ + "tests/test_old.py": _change(['P = "README.md"\n'], ["def test_x():\n", " pass\n"]), + })] + assert _hit_files(verify, facts) == [] + + +# ---------- 定数と import を経由する ---------- + +def test_an_added_line_using_an_existing_constant_hits(verify): + """`SKILL = ROOT / "SKILL.md"` の形の定数を、追加行が使っていれば当たる。""" + before = ['SKILL = ROOT / "SKILL.md"\n', "\n"] + facts = [_fact({ + "tests/test_skill.py": _change( + before, ["def test_x():\n", ' assert "手順" in SKILL.read_text()\n']), + })] + assert verify.doc_wording_tests(facts, TRACKED) == [("tests/test_skill.py", "SKILL.md")] + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, + text=True, check=True) + + +@pytest.fixture +def repo(tmp_path): + """補助モジュールだけを先に置き、テストのファイルを次のコミットで足す作業ディレクトリ。""" + work = tmp_path / "repo" + (work / "tests").mkdir(parents=True) + _git("init", "-q", "-b", "main", cwd=work) + _git("config", "user.email", "t@e.st", cwd=work) + _git("config", "user.name", "test", cwd=work) + (work / "README.md").write_text("# x\n") + (work / "tests" / "doc_helpers.py").write_text( + "import pathlib\nROOT = pathlib.Path(__file__).parents[1]\n" + 'DOC = ROOT / "README.md"\nTMP = "a.md"\n') + _git("add", "-A", cwd=work) + _git("commit", "-qm", "init", cwd=work) + return work + + +def _commit_test(work, body: str) -> str: + (work / "tests" / "test_doc.py").write_text(body) + _git("add", "-A", cwd=work) + _git("commit", "-qm", "add test", cwd=work) + return _git("rev-parse", "HEAD", cwd=work).stdout.strip() + + +def test_an_added_line_using_a_constant_imported_from_a_helper_hits(verify, gitfacts, repo): + """同じディレクトリの補助モジュールから import した定数も追う。 + + 補助モジュールはこの群で触っていないため、git から読む。 + """ + sha = _commit_test(repo, ( + "from doc_helpers import DOC, TMP\n\n" + "def test_doc():\n" + ' assert "x" in DOC.read_text()\n')) + facts = [{"sha": sha, "exists": True, + "test_changes": gitfacts.commit_test_changes(str(repo), sha)}] + hits = verify.doc_wording_tests(facts, ["README.md"], str(repo)) + assert hits == [("tests/test_doc.py", "README.md")] + + +def test_an_imported_constant_naming_an_untracked_file_does_not_hit(verify, gitfacts, repo): + sha = _commit_test(repo, ( + "from doc_helpers import TMP\n\n" + "def test_tmp(tmp_path):\n" + " (tmp_path / TMP).write_text('x')\n")) + facts = [{"sha": sha, "exists": True, + "test_changes": gitfacts.commit_test_changes(str(repo), sha)}] + assert verify.doc_wording_tests(facts, ["README.md"], str(repo)) == [] + + +def test_the_tracked_markdown_list_comes_from_git(gitfacts, repo): + assert gitfacts.tracked_markdown(str(repo)) == ["README.md"] + + +# ---------- 適用ラウンドの検証への配線 ---------- + +def test_the_apply_round_fails_with_the_reason(verify): + items = [{"item_id": "T1-001", "technique": "", "estimated_diff_lines": 100, + "path": "tests/test_full.py"}] + facts = [_fact({ + "tests/test_full.py": _change([], [' p = ROOT / "README.md"\n']), + })] + problem = verify.verify_apply_round(items, facts, tracked_md=TRACKED) + assert problem is not None + assert problem.startswith(REASON) + assert "tests/test_full.py: README.md" in problem + + +def test_the_apply_round_passes_a_temporary_markdown(verify): + items = [{"item_id": "T1-001", "technique": "", "estimated_diff_lines": 100, + "path": "tests/test_tmp.py"}] + facts = [_fact({ + "tests/test_tmp.py": _change([], [' p = tmp_path / "a.md"\n']), + })] + assert verify.verify_apply_round(items, facts, tracked_md=TRACKED) is None diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_test_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_test_rounds.py index 7498ec9a..64962dfd 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_test_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_test_rounds.py @@ -93,6 +93,27 @@ def test_a_proposal_without_a_target_is_dropped(refactor): assert adopted == [] and deferred == [] +# ---------- 文書を対象にした提案は採らない(#723) ---------- + +def test_a_proposal_targeting_a_markdown_section_is_deferred(refactor): + """`target` の `#` より前が `.md` の提案を、理由付きで見送る。 + + 文書の振る舞いは文言そのものであり、固定すると後の整理が取り消される。 + """ + adopted, deferred = _merge(refactor, { + "codex": [tprop(target="plugins/ndf/skills/x/SKILL.md#手順")], + }) + assert adopted == [] + assert deferred[0]["target"] == "plugins/ndf/skills/x/SKILL.md#手順" + assert deferred[0]["defer_reason"] == "文書の文言を固定するテストは足さない" + + +def test_a_proposal_whose_symbol_mentions_markdown_is_still_adopted(refactor): + """見るのは `#` より前のファイルだけ。シンボル側の `.md` は見ない。""" + adopted, _ = _merge(refactor, {"codex": [tprop(target="src/doc.py#render.md")]}) + assert len(adopted) == 1 + + def test_the_vocabulary_comes_from_the_existing_references(vocabulary): """`case` は現状固定テストの表、`level` はテストの階層から採る(決定 9)。""" assert list(vocabulary.TEST_CASES) == ["normal", "branch", "boundary", "error"] From a908cf1bfd252ab1ebab9eb333706e97f562d4d1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 13:12:25 +0000 Subject: [PATCH 07/19] =?UTF-8?q?Docs:=20#723=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E6=95=B4=E5=82=99=E3=81=AE=E6=8F=90=E6=A1=88=E3=81=8C?= =?UTF-8?q?=20.md=20=E3=81=AE=E6=96=87=E8=A8=80=E3=82=92=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E6=8C=99?= =?UTF-8?q?=E3=81=92=E3=81=AA=E3=81=84=E3=81=A8=E5=AE=9A=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md b/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md index 1a264189..aa8fb3cc 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md @@ -92,4 +92,7 @@ $RF_VOCAB_LEVELS - **構造を変える提案はしない。** それは次のラウンドで扱います - **実装詳細に結合したテストを提案しない**(内部メソッドの呼び出し回数、private な 関数の直接呼び出し、表示文字列の完全一致) +- **`.md` の文言・見出し・表の並びを固定するテストを提案しない。** `target` に `.md` の + ファイルを書いた提案は採られません。文書の振る舞いは文言そのものであり、固定すると + 後の文書の整理が振る舞いの変更として取り消されます - 既に固定されている経路を挙げない From 39edcb6525418d254d0e6fa38a96ffb3a4b4f790 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 14:26:50 +0000 Subject: [PATCH 08/19] =?UTF-8?q?Docs:=20#880=20AC2=20=E3=81=AE=E6=9C=80?= =?UTF-8?q?=E7=B5=82=E3=82=B2=E3=83=BC=E3=83=88=E3=81=AE=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E3=82=B3=E3=83=9F=E3=83=83=E3=83=88=E3=81=AE=E6=89=B1=E3=81=84?= =?UTF-8?q?=E3=82=92=20#436=20=E6=B1=BA=E5=AE=9A=2011=20=E3=81=AB=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=80=81=E6=B1=BA=E5=AE=9A=2014=20=E3=82=92?= =?UTF-8?q?=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- issues/issue-880-885-design-decisions.md | 11 +++++++++++ issues/issue-880-885-design.md | 2 +- issues/issue-880-885-requirements.md | 2 +- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/issues/issue-880-885-design-decisions.md b/issues/issue-880-885-design-decisions.md index 81f3a455..5479acbe 100644 --- a/issues/issue-880-885-design-decisions.md +++ b/issues/issue-880-885-design-decisions.md @@ -121,3 +121,14 @@ cross-refactoring のコードとテストに閉じ、範囲のテストがあ P3' を残す形は採らない。文書の値の照合であることは言い回しの照合と変わらず、残すと「どこまでが一致の 検査か」を毎回判断することになる。削った結果、文書とコードが食い違っても気付けない場面は増える。 これは #885 が受け入れた費用である。 + +### 決定 14: 最終ゲートの修正コミットはテストを走らせず、直後の最終ゲートが判定する + +AC2 は当初、最終ゲートの修正のコミットでも範囲のテストを走らせると定めていた。実装で、これが #436 の +決定 11(`--ci-check` を渡した実行では手元のテストを 1 度も走らせない排他)と衝突すると分かった。 +修正コミットごとに範囲のテストを走らせると、`--ci-check` の実行でこの経路だけが手元のテストを走らせる。 + +今の振る舞い(修正コミットは範囲と申告だけを見て、テストの合否は直後の `final-gate` が採った側 — +手元の全体テスト / 継続的統合 — で 1 度だけ見る)を採る。最終ゲートの修正は全体テストの失敗を +直すものであり、範囲のテストを通しても直ったかは分からない。見るべきものは直後の最終ゲートが見る。 +AC2 の文言をこれに合わせて直した。 diff --git a/issues/issue-880-885-design.md b/issues/issue-880-885-design.md index 5a1d0013..6c246866 100644 --- a/issues/issue-880-885-design.md +++ b/issues/issue-880-885-design.md @@ -127,7 +127,7 @@ graph LR | 引数 | 意味 | 既定 | 実行する時点 | | --- | --- | --- | --- | | `--baseline-test CMD` | 全体のテスト(必須) | — | `init` の 1 回、最終ゲートの 1 回 | -| `--round-test CMD` | 範囲のテスト(任意) | `--baseline-test` と同じ | `init` の 1 回、群の検証(`verify-round`)ごと、修正コミットごと、最終ゲートの修正コミットごと | +| `--round-test CMD` | 範囲のテスト(任意) | `--baseline-test` と同じ | `init` の 1 回、群の検証(`verify-round`)ごと、修正コミットごと(最終ゲートの修正コミットは走らせない。決定 14) | 状態ファイルに足す欄: diff --git a/issues/issue-880-885-requirements.md b/issues/issue-880-885-requirements.md index 06a3f785..703d343d 100644 --- a/issues/issue-880-885-requirements.md +++ b/issues/issue-880-885-requirements.md @@ -62,7 +62,7 @@ PR #844(#829 #830)の `--scope` は 6 ファイルだった。`--baseline-te ### 群ごとの検証(#880) - [ ] AC1: `refactor.py init` が `--round-test CMD` を受け付け、状態ファイルの `round_test.command` に保存する。渡さなければ `round_test` は `baseline_test` と同じコマンドになる(今の振る舞い)。このとき `init` のテストの実行は 1 回 -- [ ] AC2: `verify-round`(群の検証)と、修正ラウンド・最終ゲートの修正のコミットの検証が、`round_test` のコマンドを実行する。`baseline_test` のコマンドを実行しない +- [ ] AC2: `verify-round`(群の検証)と、修正ラウンドの修正のコミットの検証が、`round_test` のコマンドを実行する。`baseline_test` のコマンドを実行しない。最終ゲートの修正のコミットはテストを実行せず、直後の最終ゲートが 1 度だけ判定する(決定 14) - [ ] AC3: `--baseline-test` の実行は `init` の 1 回と最終ゲートの 1 回に限られる。最終ゲートは `--workflow-step` の有無に依らず、`round_test` が `baseline_test` と違えば手元で 1 回実行する(`--ci-check` があれば今のとおり継続的統合で代える) - [ ] AC4: `init` は `--round-test` を 1 回実行し、成功しなければ止まる(テストが 1 件も集まらない終了コード 5 も失敗)。`--scope` のテストの置き場所が `--round-test` の実行集合に入らなければ止まる(今の `scope_problem` を `round_test` へ当てる)。実行集合の起点は `--round-test` の引数のうち、オプションの値と作業ディレクトリの根を除いた、実在するディレクトリとテストの置き場所に当たる実在するファイルである(ラッパーのスクリプトは数えない。規則は設計の `round_test_roots`)。置き場所それぞれについて起点のどれかが同じか祖先でなければならない。起点が 1 つも無いコマンドは全体を覆うとみなす - [ ] AC5: `--round-test` を渡さず、`--baseline-test` が `--scope` のテストの置き場所より広いとき(探索の起点が無いときを含む)、`init` が 1 行の案内を出す。案内は `--round-test` に範囲のテストを渡せることを伝える。止まらない From 31c5e5bce6e53cf5be14411d6fef19b1e41ec7b1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 14:41:07 +0000 Subject: [PATCH 09/19] =?UTF-8?q?Test:=20cross-refactoring=20=E3=81=AE?= =?UTF-8?q?=E6=9C=AA=E5=9B=BA=E5=AE=9A=E7=B5=8C=E8=B7=AF=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 状態探索、適用群の絞り込み、起動失敗、壊れた提案結果、バイナリ差分の現状動作を固定する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../tests/test_apply_rounds.py | 59 +++++++++++++++++++ .../cross-refactoring/tests/test_assess.py | 17 ++++++ .../tests/test_launch_agy_phases.py | 52 ++++++++++++++++ .../tests/test_merge_proposals.py | 24 ++++++++ .../cross-refactoring/tests/test_paths.py | 20 +++++++ 5 files changed, 172 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py index 2daa0f85..9b12b0ff 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py @@ -9,12 +9,19 @@ """ from __future__ import annotations +import os +import pathlib +import re +import subprocess import sys import pytest from crossref_helpers import make_state, read_state, write_result +LAUNCH = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "launch-cli.sh" + + def prop(path="src/a.py", symbol="f", **over): base = { "path": path, "symbol": symbol, "smell": "long_method", @@ -318,6 +325,58 @@ def test_phase_after_group_returns_to_propose_when_every_group_is_verified(round assert rounds.phase_after_group(entry) == "propose" +def _prompt_item_ids(tmp_path, apply_round, phase): + rounds = [ + {"round": 1, "apply_round": apply_round}, + {"round": 2}, + ] + items = [ + {"item_id": "R1-001", "round": 1, "apply_round": 1}, + {"item_id": "R1-002", "round": 1, "apply_round": 2}, + {"item_id": "R1-003", "round": 1}, + {"item_id": "R2-001", "round": 2}, + ] + state_path = make_state(tmp_path, rounds=rounds, items=items) + (tmp_path / "work").mkdir(exist_ok=True) + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + stub = bin_dir / "codex" + stub.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + stub.chmod(0o755) + + subprocess.run( + [str(LAUNCH), "codex", phase, "130", "1"], + env={ + **os.environ, + "CROSS_REFACTORING_TMP_DIR": str(state_path.parent), + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + }, + check=True, + capture_output=True, + text=True, + ) + prompt = state_path.parent / f"codex-{phase}-r1-prompt.md" + text = prompt.read_text(encoding="utf-8") + items_block = re.search( + r"## (?:適用する項目|この適用ラウンドの項目).*?```json\n(.*?)\n```", + text, + re.DOTALL, + ) + assert items_block is not None + return set(re.findall(r"R\d+-\d{3}", items_block.group(1))) + + +@pytest.mark.parametrize("phase", ["apply", "fix"]) +def test_launch_prompt_contains_only_the_current_apply_round(tmp_path, phase): + assert _prompt_item_ids(tmp_path, 2, phase) == {"R1-002"} + + +def test_launch_prompt_contains_the_whole_round_before_a_group_is_selected(tmp_path): + assert _prompt_item_ids(tmp_path, 0, "apply") == { + "R1-001", "R1-002", "R1-003" + } + + def test_phase_after_group_returns_to_propose_when_there_is_no_group(rounds): """現状固定: 群が空なら適用するものが無く、提案へ戻る。""" entry = _round_with_groups([], items=()) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_assess.py b/plugins/ndf/skills/cross-refactoring/tests/test_assess.py index 66365fdc..6aff3f20 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_assess.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_assess.py @@ -117,3 +117,20 @@ def test_unresolvable_base_exits_2(repo): rc, out = _assess(repo, "--base", "no-such-ref") assert rc == 2 assert out == [] + + +def test_binary_production_file_counts_as_zero_lines(repo): + """numstat が `-` を返すコード拡張子のバイナリも、現状どおり 0 行と数える。""" + path = repo / "src" / "binary.py" + path.write_bytes(b"before\0after") + _git("add", "-A", cwd=repo) + _git("commit", "-qm", "change", cwd=repo) + + rc, out = _assess(repo, "--base", "base") + + assert rc == 3 + assert out == [ + "判定: 飛ばしてよい", + "理由: 本番コードの変更が 0 行で、上限 10 行以下です", + "本番コード: 1 ファイル・0 行(src/binary.py)", + ] diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py b/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py index 6ca8462f..d2352e50 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py @@ -125,3 +125,55 @@ def test_the_workspace_and_stem_for_the_remaining_phases(tmp_path, phase: str) - added = [args[i + 1] for i, a in enumerate(args) if a == "--add-dir"] assert added == [str(tmp_path / workdir_name), str(state_path.parent)] assert (state_path.parent / f"{stem}-prompt.md").is_file() + + +def _run_codex(tmp_path, rounds, *args): + state_path = make_state(tmp_path, rounds=rounds) + (tmp_path / "work").mkdir(exist_ok=True) + (tmp_path / "codex").mkdir(exist_ok=True) + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + args_file = tmp_path / "args.txt" + stub = bin_dir / "codex" + stub.write_text(STUB, encoding="utf-8") + stub.chmod(0o755) + result = subprocess.run( + [str(LAUNCH), *args], + env={ + **os.environ, + "CROSS_REFACTORING_TMP_DIR": str(state_path.parent), + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "NDF_TEST_ARGS_FILE": str(args_file), + }, + capture_output=True, + text=True, + ) + return result, state_path, args_file + + +def test_apply_without_a_round_stops_when_no_round_exists(tmp_path): + result, state_path, args_file = _run_codex(tmp_path, [], "codex", "apply", "130") + assert result.returncode != 0 + assert not args_file.exists() + assert not (state_path.parent / "codex-apply-r0-prompt.md").exists() + + +def test_apply_without_a_round_uses_the_latest_round(tmp_path): + result, state_path, args_file = _run_codex( + tmp_path, [{"round": 1}, {"round": 2}], "codex", "apply", "130" + ) + assert result.returncode == 0 + for _ in range(200): + if args_file.is_file(): + break + time.sleep(0.05) + assert args_file.exists() + assert (state_path.parent / "codex-apply-r2-prompt.md").is_file() + + +def test_unknown_runtime_stops_before_writing_a_prompt(tmp_path): + result, state_path, _ = _run_codex( + tmp_path, [{"round": 1}], "unknown", "propose", "130", "1" + ) + assert result.returncode != 0 + assert not (state_path.parent / "unknown-propose-rf130-r1-prompt.md").exists() diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_merge_proposals.py b/plugins/ndf/skills/cross-refactoring/tests/test_merge_proposals.py index 008ab1ed..7d77e5ae 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_merge_proposals.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_merge_proposals.py @@ -245,6 +245,30 @@ def test_non_object_proposal_result_is_treated_as_empty( assert len(state["items"]) == 1 +def test_invalid_json_proposal_result_is_treated_as_empty( + refactor, cmd_apply, tmp_path, env_tmp_dir, no_git +): + """壊れた JSON の 1 者を提案なしとして、残りの提案を統合し続ける。""" + state_path = make_state(tmp_path, rounds=[{ + "round": 1, "impl": "codex", "reviewers": ["agy", "kiro"], + "impl_model": {"requested": None, "observed": None}, + "reviewer_models": {}, "proposed": {}, "items": [], + "apply": {"applied": [], "failed": []}, "fix_rounds": 0, + "durations": {}, "reviews": [], + }]) + env_tmp_dir(state_path) + broken = state_path.parent / "codex-propose-rf130-r1-result.json" + broken.write_text("{not valid json", encoding="utf-8") + write_result(state_path, "agy-propose-rf130-r1", {"items": [proposal()]}) + write_result(state_path, "kiro-propose-rf130-r1", {"items": [proposal()]}) + + cmd_apply.cmd_merge_proposals(type("A", (), {"id": 130})()) + + state = read_state(state_path) + assert state["rounds"][0]["proposed"].get("codex", 0) == 0 + assert len(state["items"]) == 1 + + def test_merge_proposals_is_idempotent(cmd_apply, tmp_path, env_tmp_dir, no_git): """同じラウンドで叩き直しても項目を二重に作らないこと。 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_paths.py b/plugins/ndf/skills/cross-refactoring/tests/test_paths.py index 3d2bc91a..8dec273e 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_paths.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_paths.py @@ -11,6 +11,8 @@ import pathlib import tempfile +import pytest + STATE_ID = 130 @@ -85,6 +87,24 @@ def test_it_uses_the_cwd_when_the_env_var_is_unset(paths, tmp_path, monkeypatch) assert state["phase"] == "from-cwd" +def test_load_state_exits_4_when_the_target_is_missing( + paths, tmp_path, monkeypatch, capsys +): + """現状固定: 指定先と現在地のどちらにも対象 ID が無ければ停止する。""" + env_dir = tmp_path / "env" + env_dir.mkdir() + cwd = tmp_path / "cwd" + cwd.mkdir() + monkeypatch.setenv("CROSS_REFACTORING_TMP_DIR", str(env_dir)) + monkeypatch.chdir(cwd) + + with pytest.raises(SystemExit) as exc: + paths.load_state(STATE_ID) + + assert exc.value.code == 4 + assert str(STATE_ID) in capsys.readouterr().err + + def test_the_explicit_worktree_base_is_resolved(paths, tmp_path, monkeypatch): """現状固定: 明示した作業ディレクトリの親を絶対パスへ解決する。""" monkeypatch.chdir(tmp_path) From 9ce9ee08340a36018743439feb26e26338feea26 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 15:04:16 +0000 Subject: [PATCH 10/19] =?UTF-8?q?Test:=20cross-refactoring=20=E6=8F=90?= =?UTF-8?q?=E6=A1=88=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=202=20=E3=81=AE?= =?UTF-8?q?=E6=9C=AA=E5=9B=BA=E5=AE=9A=E7=B5=8C=E8=B7=AF=E3=82=92=E7=8F=BE?= =?UTF-8?q?=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=81=A7?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 打ち切り・取り消し(mode=round)・最終ゲートの打ち切り・完了報告の分岐・ 実測モデルの反映という、これまでどのテストも通っていなかった 5 つの経路を 現状固定テストで固定する。対象のコードは変更しない。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../tests/test_abandon_items.py | 157 ++++++++++++++++++ .../tests/test_final_gate.py | 35 ++++ .../cross-refactoring/tests/test_init.py | 55 ++++++ .../tests/test_models_and_metrics.py | 31 ++++ .../cross-refactoring/tests/test_rounds.py | 129 ++++++++++++++ 5 files changed, 407 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py index 697710ff..f017764f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py @@ -1160,3 +1160,160 @@ def test_fix_commits_are_verified_with_the_round_test(patch_lib, refactor, gitfa assert ran == ["pytest tests/services -q"] assert "fix111" in read_state(state_path)["items"][0]["commits"] + + +# ---------- run_drop が mode=round を返したときの取り消し(R2-002) ---------- +# +# 現状固定テスト。適用の検証に落ちた群を、run_drop が「積み直せなかった」 +# (mode=round)で返したときに cmd_merge_apply(_apply_drop)が行う状態の +# 書き換えは、どのテストも通していなかった(apply.py 1008-1017 が未到達)。 +# gitfacts.drop_items が round 退避を返すこと自体は test_drop_items_git.py が +# 固定しているので、ここではそれを受けた cmd_merge_apply 側だけを、run_drop を +# 差し替えて固定する。mode=item の側を対照として並べ、分岐の両側を固定する。 + + +def _fact_for(sha, **over): + """`collect_commit_facts()` が git から作る事実。""" + base = { + "sha": sha, "exists": True, "test_status": "pass", + "touches_tests": False, "diff_lines": 30, + "trailers": {"Item-Id": "R1-001", "Round": "1", + "Impl-Runtime": "codex", "Impl-Model": "gpt-5.5"}, + } + base.update(over) + return base + + +def _two_item_group_state(tmp_path): + """群に 2 項目を持ち、検証に落ちる(片方の申告が欠ける)適用結果の状態。""" + from crossref_helpers import write_result + items = [_item("R1-001", []), _item("R1-002", [])] + for it in items: + it["status"] = "pending" + state_path = make_state( + tmp_path, + items=items, + rounds=[{ + "round": 1, "impl": "codex", "reviewers": REVIEWERS, + "impl_model": {"requested": "gpt-5.5", "observed": None}, + "reviewer_models": {r: {"requested": None, "observed": None} + for r in REVIEWERS}, + "proposed": {}, "merged": 2, "adopted": 2, "deferred": 0, + "items": ["R1-001", "R1-002"], + "apply_rounds": [{ + "apply_round": 1, "impl": "codex", + "impl_model": {"requested": "gpt-5.5", "observed": None}, + "items": ["R1-001", "R1-002"], "status": "pending", + "base_sha": None, "head_sha": None, "fix_rounds": 0, + }], + "apply_round": 1, + "apply": {"applied": [], "failed": []}, "fix_rounds": 0, + "durations": {}, "reviews": [], + }], + ) + write_result(state_path, "codex-apply-r1", { + "base_sha": "aaa", "elapsed_seconds": 100, + "items": [ + # 群は 1 コミットを共有する。所有権の検査は通るが、コミット自体が + # 検証(トレーラー欠け)に落ちて群の全件が失敗する + {"item_id": "R1-001", "commits": [{"sha": "ok111"}]}, + {"item_id": "R1-002", "commits": [{"sha": "ok111"}]}, + ], + }) + return state_path + + +def _stub_apply_facts(patch_lib): + """適用の検証が git から取る事実を差し替える。ok111 は範囲にあるが検証に落ちる。""" + # `Impl-Model` を欠いたトレーラーにして、群の検証を失敗させる。 + bad_trailers = {"Item-Id": "R1-001", "Round": "1", "Impl-Runtime": "codex"} + mapping = {"ok111": _fact_for("ok111", trailers=bad_trailers)} + patch_lib("commits_in_range", lambda work, base, head: ["ok111"]) + patch_lib("collect_commit_facts", + lambda work, shas, rng, cmd, branch, timeout=None: [ + mapping.get(s, {"sha": s, "exists": False}) for s in shas]) + + +def test_merge_apply_drops_the_whole_group_when_run_drop_returns_round( + patch_lib, refactor, tmp_path, env_tmp_dir, monkeypatch +): + """R2-002 — run_drop が mode=round のとき、群の全件を abandoned にする(apply.py 1008-1017)。""" + state_path = _two_item_group_state(tmp_path) + env_tmp_dir(state_path) + _stub_apply_facts(patch_lib) + # `rev-parse HEAD` は固定の SHA。他の git 呼び出しは SHA をそのまま返す。 + patch_lib("git_out", + lambda work, args, **_kw: "DROPPED_HEAD" if args == ["rev-parse", "HEAD"] + else args[-1].replace("^{commit}", "")) + dropped: list = [] + patch_lib("run_drop", + lambda path, state, entry, failed: dropped.append(list(failed)) + or {"mode": "round", "dropped": [], "reverted": 2, "replayed": 0}) + _apply_mod = sys.modules["refactor_lib.commands.apply"] + patch_lib("push_with_retry_marker", + lambda path, state, entry: _apply_mod.statefile.save(path, state)) + monkeypatch.setattr(sys.modules["refactor_lib.commands.apply"].statefile, "now", + lambda: "2026-09-23T00:00:00") + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply(_args()) + assert e.value.code == 2 + + state = read_state(state_path) + entry = state["rounds"][0] + by_id = {i["item_id"]: i for i in state["items"]} + # 群の 2 項目がともに abandoned で failure_reason を持つ + assert by_id["R1-001"]["status"] == "abandoned" + assert by_id["R1-002"]["status"] == "abandoned" + assert by_id["R1-001"]["failure_reason"] and by_id["R1-002"]["failure_reason"] + # apply.applied は空、apply.failed は群の全項目 + assert entry["apply"]["applied"] == [] + assert entry["apply"]["failed"] == ["R1-001", "R1-002"] + # apply_base_sha は取り直した固定の SHA + assert entry["apply_base_sha"] == "DROPPED_HEAD" + # deferred_items に 2 項目が載る + assert {d["item_id"] for d in state["deferred_items"]} == {"R1-001", "R1-002"} + + +def test_merge_apply_keeps_run_drops_item_result_when_mode_is_item( + patch_lib, refactor, tmp_path, env_tmp_dir, monkeypatch +): + """R2-002(対照)— run_drop が mode=item のとき、_apply_drop は群を一括で abandoned にしない。 + + 項目単位の abandoned は run_drop(drop_items)側の責務なので、ここでは + fake run_drop がそれを模す。_apply_drop は apply.failed を群全体へ書き換えない。 + """ + state_path = _two_item_group_state(tmp_path) + env_tmp_dir(state_path) + _stub_apply_facts(patch_lib) + patch_lib("git_out", + lambda work, args, **_kw: "DROPPED_HEAD" if args == ["rev-parse", "HEAD"] + else args[-1].replace("^{commit}", "")) + + def fake_item_drop(path, state, entry, failed): + # 実際の drop_items(mode=item)は失敗した項目だけを abandoned にする + for item_id in failed: + find = next(i for i in state["items"] if i["item_id"] == item_id) + find["status"] = "abandoned" + find.setdefault("failure_reason", "項目単位で取り消した") + return {"mode": "item", "dropped": list(failed), "reverted": 1, "replayed": 1} + + patch_lib("run_drop", fake_item_drop) + _apply_mod = sys.modules["refactor_lib.commands.apply"] + patch_lib("push_with_retry_marker", + lambda path, state, entry: _apply_mod.statefile.save(path, state)) + monkeypatch.setattr(sys.modules["refactor_lib.commands.apply"].statefile, "now", + lambda: "2026-09-23T00:00:00") + + with pytest.raises(SystemExit) as e: + refactor.cmd_merge_apply(_args()) + assert e.value.code == 2 + + state = read_state(state_path) + entry = state["rounds"][0] + # mode=item では _apply_drop が apply.failed を群全体へ置き換えない + # (検証の記録のまま。群の全件が failed のまま残る)。 + assert entry["apply"]["failed"] == ["R1-001", "R1-002"] + # applied は検証で空になっており、_apply_drop はここで群を明示的に + # 上書きしない(round の分岐だけが上書きする)。 + assert entry["apply"]["applied"] == [] diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_final_gate.py b/plugins/ndf/skills/cross-refactoring/tests/test_final_gate.py index ce76a28c..9f859bba 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_final_gate.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_final_gate.py @@ -374,3 +374,38 @@ def test_the_gate_check_records_the_command_and_seconds( assert check["mode"] == "test" assert check["command"] == "pytest -q" assert isinstance(check["seconds"], (int, float)) and check["seconds"] >= 0 + + +# ---------- 全体テストの打ち切り(run_with_timeout の timed_out=True)(R2-003) ---------- +# +# 現状固定テスト。最終ゲートの成功と非ゼロ終了は固定されているが、全体テストが +# 打ち切り(timed_out=True)で止まったときに、失敗として記録し修正ラウンドへ進む +# 経路は固定されていなかった(gate.py の `_local_gate` の timed_out 分岐)。 + + +def test_final_gate_records_a_timed_out_whole_test_and_enters_a_fix_round( + patch_lib, refactor, cmd_gate, tmp_path, env_tmp_dir, spy, capsys +): + """R2-003 — 全体テストが打ち切りなら失敗として記録し、修正ラウンドへ進む。""" + state_path = _state(tmp_path, workflow_step=True, test_timeout=60) + env_tmp_dir(state_path) + # 全体テストの実行を打ち切りへ差し替える(spy の差し替えを上書きする)。 + patch_lib("run_with_timeout", + lambda command, cwd, timeout, grace=5.0: (None, True)) + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_final_gate(_args()) + # 現状固定: 失敗の終了コード(修正ラウンドへ)。 + assert e.value.code == 2 + out = capsys.readouterr().out + assert "FINAL_GATE=failing" in out and "FINAL_GATE=cross-review" not in out + + gate = read_state(state_path)["final_gate"] + # 修正ラウンドへ進む。 + assert gate["fix_rounds"] == 1 + assert gate["status"] == "failing" + # 最終ゲートの記録は「打ち切り」相当の詳細を持つ(文言の完全一致は取らず、 + # 打ち切った秒数が含まれることだけを見る)。 + check = gate["checks"][-1] + assert check["status"] == "fail" + assert "60" in check["detail"] diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index 3319690f..561eaf1a 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -839,3 +839,58 @@ def test_resume_notifies_a_changed_round_test(run_init, tmp_path, capsys): _, after = _state_of(tmp_path) assert "ℹ --round-test は再開では反映しません" in capsys.readouterr().err assert after["round_test"] == before["round_test"] + + +# ---------- 打ち切り(run_with_timeout の timed_out=True)(R2-001) ---------- +# +# 現状固定テスト。着手前のテストと範囲のテストが「打ち切り」で止まる 2 経路 +# (setup.py の `_run_baseline_test` / `_run_round_test` の timed_out=True)は +# どのテストも通していなかった。失敗(終了コード非 0)とは別の分岐なので、現状の +# 終了コードと、状態ファイルが書かれないことをそのまま記録する。 + + +@pytest.fixture +def timeout_calls(patch_lib): + """`init` のテスト実行を差し替え、コマンドごとに打ち切り(timed_out=True)へ倒せる。 + + `timed_out` に載せたコマンドだけ `(None, True)` を返す。それ以外は `(0, False)`。 + """ + seen: list[str] = [] + timed_out: set[str] = set() + + def fake_run(command, cwd, timeout, grace=5.0): + seen.append(command) + if command in timed_out: + return None, True + return 0, False + + patch_lib("run_with_timeout", fake_run) + return type("Calls", (), {"seen": seen, "timed_out": timed_out})() + + +def test_init_aborts_when_the_baseline_test_times_out(run_init, tmp_path, timeout_calls, capsys): + """R2-001 — 着手前のテストが打ち切りで止まる経路(setup.py 630-634)。""" + timeout_calls.timed_out.add("true") + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, baseline_test="true", test_timeout=60)) + # 現状固定: `die` の既定の終了コード(打ち切り)。 + assert e.value.code == refactor_abort() + # 状態ファイルは打ち切りの後の保存に届かないため書かれない。 + assert not _state_path(tmp_path).exists() + # 出力は文言の完全一致を取らず、打ち切った秒数(60)が含まれることだけを見る。 + assert "60" in capsys.readouterr().err + + +def test_init_aborts_when_the_round_test_times_out(run_init, tmp_path, timeout_calls, capsys): + """R2-001 — 範囲のテストが打ち切りで止まる経路(setup.py 662-663)。""" + # 着手前のテストは通し、範囲のテストだけ打ち切る。 + timeout_calls.timed_out.add("pytest -q -k scope") + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, round_test="pytest -q -k scope", baseline_test="true", + test_timeout=60)) + # 現状固定: 範囲のテストの打ち切りは ABORT。 + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + # 着手前のテストを通したあと、範囲のテストで止まる順序。 + assert timeout_calls.seen == ["true", "pytest -q -k scope"] + assert "60" in capsys.readouterr().err diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py b/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py index 4a20f613..8477f49f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py @@ -347,3 +347,34 @@ def test_models_are_fixed_across_rounds(cmd_setup, tmp_path, env_tmp_dir): # 次のラウンドを開けるように、いま開いたラウンドを閉じる entry["adopted"] = 1 state_path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") + + +# ---------- 実測モデルを状態へ反映する正常経路(R2-005) ---------- +# +# 現状固定テスト。モデル文字列の解析(observed_model)と不一致判定 +# (mismatch_warning)は個別に固定されているが、CLI の stdout ログから得た +# 実測モデルを、適用ラウンドの状態へ反映する正常経路(gitfacts.record_observed_model) +# はどのテストも通していなかった。 + + +def test_record_observed_model_saves_the_observed_value( + gitfacts, tmp_path +): + """R2-005 — stdout ログの実測モデルを impl_model.observed へ保存する。""" + tmp_dir = tmp_path / "tmp" + tmp_dir.mkdir() + # 適用フェーズ・ラウンド 1 の骨格は `claude-apply-r1`。 + (tmp_dir / "claude-apply-r1-stdout.log").write_text( + json.dumps({ + "type": "result", "is_error": False, + "modelUsage": {"claude-opus-5": {"inputTokens": 100}}, + }), + encoding="utf-8", + ) + state = {"id": 130, "tmp_dir": str(tmp_dir)} + entry = {"impl_model": {"requested": "claude-opus-5", "observed": None}} + + gitfacts.record_observed_model(entry, "claude", state, "apply", 1) + + # 現状固定: stdout ログから拾った実測値が保存される。 + assert entry["impl_model"]["observed"] == "claude-opus-5" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py index d6adf114..c570b91b 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py @@ -238,3 +238,132 @@ def test_report_says_no_record_for_an_older_state(cmd_report, tmp_path, env_tmp_ env_tmp_dir(state_path) cmd_report.cmd_report(_report_args()) assert "- 使える者: 記録なし" in capsys.readouterr().out + + +# ---------- 報告の未到達分岐の固定(R2-004) ---------- +# +# 現状固定テスト。完了報告の既存テストが通っていなかった分岐を固定する。 +# 行全体の完全一致は避け、分岐の判定に関わる値の部分だけを比較する。 + + +def _participants(**over): + """参加者の記録を組み立てるヘルパ。""" + base = { + "pool": ["claude", "codex", "kiro"], "included": [], + "excluded": [], "available": ["claude", "codex", "kiro"], + "unavailable": {}, "probe_skipped": False, "require_all": False, + } + base.update(over) + return base + + +@pytest.mark.parametrize("probe_skipped, expected_text", [ + (True, "確認を飛ばした(NDF_SKIP_AUTH_CHECK)"), + (False, "確認を通らなかった者: なし"), +]) +def test_report_probe_skipped_vs_no_unavailable( + cmd_report, tmp_path, env_tmp_dir, capsys, probe_skipped, expected_text +): + """R2-004(a)(b) — unavailable が空のとき probe_skipped で出力が変わる。""" + state_path = make_state( + tmp_path, + participants=_participants(probe_skipped=probe_skipped), + resume_changes=[], + ) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + assert expected_text in capsys.readouterr().out + + +def test_report_empty_resume_changes_says_none( + cmd_report, tmp_path, env_tmp_dir, capsys +): + """R2-004(c) — 再開で変えた値が空なら「なし」と出る。""" + state_path = make_state( + tmp_path, + participants=_participants(), + resume_changes=[], + ) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + assert "再開で変えた値: なし" in capsys.readouterr().out + + +def test_report_resume_change_with_participants_shows_available( + cmd_report, tmp_path, env_tmp_dir, capsys +): + """R2-004(d) — 再開で変えた値に参加者(available を持つ dict)があれば、使える者だけを出す。""" + state_path = make_state( + tmp_path, + participants=_participants(), + resume_changes=[{ + "at": "2026-09-22T00:00:00", "field": "participants", + "from": {"available": ["claude", "codex"]}, + "to": {"available": ["claude", "codex", "kiro"]}, + }], + ) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + out = capsys.readouterr().out + assert "claude / codex → claude / codex / kiro" in out + + +def test_report_shows_test_rounds_final_when_present( + cmd_report, tmp_path, env_tmp_dir, capsys +): + """R2-004(e) — test_rounds_final があれば「テスト整備の終わり方」を出す。""" + state_path = make_state( + tmp_path, + test_rounds_final="max_test_rounds", + participants=_participants(), + ) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + out = capsys.readouterr().out + assert "テスト整備の終わり方" in out + assert "max_test_rounds" in out + + +def test_report_item_table_shows_case_and_level_for_test_items( + cmd_report, tmp_path, env_tmp_dir, capsys +): + """R2-004(f) — テスト整備の項目(kind=test)は case と level を項目表に出す。""" + test_item = { + "item_id": "R2-001", "round": 1, "kind": "test", + "path": "tests/test_init.py", + "target": "scripts/refactor_lib/commands/setup.py#cmd_init", + "case": "error", "level": "unit", + "rationale": "", "plan": "", "test_gap": False, + "estimated_diff_lines": 0, "proposed_by": ["claude"], + "status": "applied", "commits": ["abc"], + } + structure_item = { + "item_id": "R1-001", "round": 1, + "path": "src/foo.py", "symbol": "Foo.handle", + "smell": "long_method", "technique": "extract_method", "severity": "major", + "rationale": "", "plan": "", "test_gap": False, + "estimated_diff_lines": 10, "proposed_by": ["codex"], + "status": "done", "commits": ["def"], + } + state_path = make_state( + tmp_path, + items=[structure_item, test_item], + rounds=[{ + "round": 1, "kind": "structure", "impl": "codex", + "impl_model": {"requested": None, "observed": None}, + "adopted": 2, + "apply": {"applied": ["R1-001", "R2-001"], "failed": []}, + "fix_rounds": 0, "reviews": [], + }], + ) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + lines = capsys.readouterr().out.splitlines() + # テスト項目の行で case と level が出ること + test_row = next(line for line in lines if "R2-001" in line) + assert "error" in test_row + assert "unit" in test_row + # 構造改善項目の行は smell と technique が出ること(既存動作の確認) + struct_row = next(line for line in lines if "R1-001" in line) + assert "long_method" in struct_row + assert "extract_method" in struct_row From edaf4b424a926661ee95003573f127bf99171744 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 15:15:24 +0000 Subject: [PATCH 11/19] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/skills/cross-refactoring/scripts/refactor=5Flib/?= =?UTF-8?q?commands/gate.py#cmd=5Ffinal=5Fgate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提案ラウンド 3・適用ラウンド 1 の 5 項目をまとめて適用する(振る舞いは変えない)。 - R3-001 gate.py#cmd_final_gate: 結末ごとの記録を _emit_cross_review / _record_gate_check / _gate_passed / _gate_limit_reached / _gate_failing へ抽出し、本体を判定の骨格だけにする - R3-002 verify.py#verify_apply_round: 基礎検査・テスト保護・差分制約の 3 段へ分け、 _APPLY_ROUND_STAGES を順に通して最初の問題を返すパイプラインにする - R3-003 setup.py#cmd_init: 入力解決・準備・再開判定・検証・初期状態の保存の段へ分ける。 _emit_init は cmd_init から直接呼ぶ(check-skill-shell-vars.py がヘルパーを 1 段しかたどらないため) - R3-004 gitfacts.py#_record_drop_result: 取り消しと積み直しの計画を _DropPlan にまとめ、 結果の辞書を _drop_summary へ寄せる。dry-run と旧版の状態ファイルの経路を現状固定テストで固定 - R3-005 refactor.py#main: 副コマンド群ごとのパーサー登録を add_* 関数へ抽出する Item-Id: R3-001 Round: 3 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5.5 --- .../cross-refactoring/scripts/refactor.py | 36 ++++- .../scripts/refactor_lib/commands/gate.py | 91 +++++++---- .../scripts/refactor_lib/commands/setup.py | 151 ++++++++++++++---- .../scripts/refactor_lib/gitfacts.py | 81 ++++++---- .../scripts/refactor_lib/verify.py | 57 +++++-- .../tests/test_drop_items_git.py | 29 ++++ 6 files changed, 333 insertions(+), 112 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py index c838ef35..b15d0091 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py @@ -95,12 +95,8 @@ def _write_run_summary(path: pathlib.Path, state: dict) -> None: # ---------------- main ---------------- -def main() -> None: - p = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter - ) - sub = p.add_subparsers(dest="cmd", required=True) - +def add_init_parser(sub: argparse._SubParsersAction) -> None: + """`init` を登録する。""" init = sub.add_parser( "init", help="Step 0 — ホスト確定 / 参加者の確定 / 作業ディレクトリ root / 状態初期化・再開") @@ -181,6 +177,9 @@ def main() -> None: init.add_argument("--worktree-root", default=None) init.set_defaults(func=cmd_init) + +def add_id_commands(sub: argparse._SubParsersAction) -> None: + """提案ラウンドの番号 `id` だけを受け取る副コマンドを登録する。""" for name, func, help_ in ( ("start-round", cmd_start_round, "Step 2 — 提案ラウンドを開く。実装担当を返す"), @@ -198,6 +197,9 @@ def main() -> None: sp.add_argument("id", type=int) sp.set_defaults(func=func) + +def add_round_commands(sub: argparse._SubParsersAction) -> None: + """`id` と適用ラウンドの `round` を受け取る副コマンドを登録する。""" for name, func, help_ in ( ("next-apply-round", cmd_next_apply_round, "Step 4 — 次の適用ラウンド(群)を開く。実装担当と対象の項目を返す"), @@ -214,6 +216,9 @@ def main() -> None: sp.add_argument("round", type=int) sp.set_defaults(func=func) + +def add_dry_run_commands(sub: argparse._SubParsersAction) -> None: + """`id` / `round` に加えて `--dry-run` を受け取る副コマンドを登録する。""" # コミットを取り消しうる 2 つは、実行前に何が消えるかを確かめられるようにする。 for name, func, help_ in ( ("merge-apply", cmd_merge_apply, @@ -228,6 +233,9 @@ def main() -> None: help="取り消すコミットを表示するだけで実行しない") sp.set_defaults(func=func) + +def add_assess_parser(sub: argparse._SubParsersAction) -> None: + """`assess` を登録する。""" ap = sub.add_parser( "assess", help="構造改善を飛ばしてよいかを差分から判定する。" @@ -239,6 +247,9 @@ def main() -> None: f"(default: {DEFAULT_MAX_LINES})") ap.set_defaults(func=cmd_assess) + +def add_report_parser(sub: argparse._SubParsersAction) -> None: + """`report` を登録する。""" rp = sub.add_parser( "report", help="Step 8 — ラウンド表・項目表・見送り・指標") rp.add_argument("id", type=int) @@ -246,6 +257,19 @@ def main() -> None: help="ランタイムとモデルの組で指標を集計する") rp.set_defaults(func=cmd_report) + +def main() -> None: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + sub = p.add_subparsers(dest="cmd", required=True) + add_init_parser(sub) + add_id_commands(sub) + add_round_commands(sub) + add_dry_run_commands(sub) + add_assess_parser(sub) + add_report_parser(sub) + args = p.parse_args() args.func(args) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index 16d45440..26eb96a1 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -67,10 +67,9 @@ def cmd_final_gate(args: argparse.Namespace) -> None: standalone = not state.get("workflow_step") if standalone and not _round_test_differs(state): - gate["mode"] = "cross-review" - statefile.save(path, state) - info("単独起動のため、Step 7 は /ndf:cross-review を実行します") - statefile.emit(FINAL_GATE="cross-review") + _emit_cross_review( + path, state, gate, "単独起動のため、Step 7 は /ndf:cross-review を実行します" + ) return # **排他である。** `--ci-check` があれば手元のテストを実行せず継続的統合の成功 @@ -82,40 +81,76 @@ def cmd_final_gate(args: argparse.Namespace) -> None: passed, detail = ( _ci_gate(state, ci_check) if ci_check else _local_gate(state) ) + _record_gate_check( + gate, ci_check or _baseline_command(state), passed, detail, + round(time.monotonic() - started, 1), + ) + + if passed and standalone: + _emit_cross_review( + path, state, gate, + f"✅ 全体のテストが通りました({detail})。Step 7 は /ndf:cross-review を実行します", + ) + return + if passed: + _gate_passed(path, state, gate, detail) + return + + limit = safe_int(state.get("max_fix_rounds"), 3) + if safe_int(gate.get("fix_rounds")) >= limit: + _gate_limit_reached(path, state, gate, detail, limit) + _gate_failing(path, state, gate, detail, limit) + + +def _emit_cross_review( + path: pathlib.Path, state: dict[str, Any], gate: dict[str, Any], message: str +) -> None: + """Step 7 を `cross-review` へ委譲する結末。""" + gate["mode"] = "cross-review" + statefile.save(path, state) + info(message) + statefile.emit(FINAL_GATE="cross-review") + + +def _record_gate_check( + gate: dict[str, Any], command: str, passed: bool, detail: str, seconds: float +) -> None: + """最終ゲートの検査 1 件を `checks` へ追記する。""" gate.setdefault("checks", []).append({ "at": statefile.now(), "mode": gate["mode"], - "command": ci_check or _baseline_command(state), + "command": command, "status": "pass" if passed else "fail", "detail": detail, - "seconds": round(time.monotonic() - started, 1), + "seconds": seconds, }) - if passed and standalone: - gate["mode"] = "cross-review" - statefile.save(path, state) - info(f"✅ 全体のテストが通りました({detail})。Step 7 は /ndf:cross-review を実行します") - statefile.emit(FINAL_GATE="cross-review") - return - if passed: - gate["status"] = "passed" - statefile.save(path, state) - info(f"✅ 最終ゲートを通過しました({detail})") - statefile.emit(FINAL_GATE="passed") - return +def _gate_passed( + path: pathlib.Path, state: dict[str, Any], gate: dict[str, Any], detail: str +) -> None: + gate["status"] = "passed" + statefile.save(path, state) + info(f"✅ 最終ゲートを通過しました({detail})") + statefile.emit(FINAL_GATE="passed") - limit = safe_int(state.get("max_fix_rounds"), 3) - if safe_int(gate.get("fix_rounds")) >= limit: - gate["status"] = "failed" - statefile.save(path, state) - info( - f"❌ 最終ゲートが通らないまま修正の上限 {limit} に達しました({detail})。" - "**既に push してあるため取り消しません。** 失敗として報告します" - ) - statefile.emit(FINAL_GATE="failed") - sys.exit(1) +def _gate_limit_reached( + path: pathlib.Path, state: dict[str, Any], gate: dict[str, Any], detail: str, limit: int +) -> None: + gate["status"] = "failed" + statefile.save(path, state) + info( + f"❌ 最終ゲートが通らないまま修正の上限 {limit} に達しました({detail})。" + "**既に push してあるため取り消しません。** 失敗として報告します" + ) + statefile.emit(FINAL_GATE="failed") + sys.exit(1) + + +def _gate_failing( + path: pathlib.Path, state: dict[str, Any], gate: dict[str, Any], detail: str, limit: int +) -> None: gate["fix_rounds"] = safe_int(gate.get("fix_rounds")) + 1 gate["status"] = "failing" # **修正の起点と担当をここで記録する。** 記録しないと `merge-final-fix` が範囲を diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index a06a9c97..84c98cf1 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -362,19 +362,73 @@ def cmd_init(args: argparse.Namespace) -> None: codex / kiro とホストを既定とし、足す者・外す者で変える。確認を通らない者は外して 続ける。前回の状態が残っていれば再開し、渡した引数を反映の表に従って扱う。 """ + inputs = _resolve_init_inputs(args) + if inputs is None: + return + prep = _prepare_init(args) + if _resume_if_pending(args, inputs, prep): + return + + for key, value in NEW_RUN_DEFAULTS.items(): + if getattr(args, key, None) is None: + setattr(args, key, value) + + participants, baseline, round_record = _verify_init(args, inputs, prep) + state = _save_initial_state(args, inputs, prep, participants, baseline, round_record) + # **出力は入口から直接呼ぶ。** 手順書の変数の出所の検査 + # (`scripts/check-skill-shell-vars.py`)は `cmd_*` からヘルパーを 1 段だけたどる。 + _emit_init(state) + + +@dataclass +class _InitInputs: + """`init` の引数から解決したホスト・モデル・足す者・外す者。""" + + host: str + detection: str + model_spec: dict[str, Optional[str]] + include: Optional[list[str]] + exclude: Optional[list[str]] + + +@dataclass +class _InitPreparation: + """Pull Request の文脈と、用意した作業ディレクトリ。""" + + repo: str + base_branch: str + head_branch: str + is_own_pr: bool + root: pathlib.Path + work: pathlib.Path + tmp_dir: pathlib.Path + state_file: pathlib.Path + round_test: Optional[str] + + +def _resolve_init_inputs(args: argparse.Namespace) -> Optional[_InitInputs]: + """ホスト・モデル・足す者・外す者を解決する。解決できなければ止めて `None` を返す。""" try: host, detection = assignment.detect_host(args.host) except assignment.AssignmentError as e: die(str(e)) - return + return None try: model_spec = models_lib.parse_model_args(args.model) except models_lib.ModelSpecError as e: die(str(e)) - return - include = _names_arg(args, "include") - exclude = _names_arg(args, "exclude") + return None + return _InitInputs( + host=host, + detection=detection, + model_spec=model_spec, + include=_names_arg(args, "include"), + exclude=_names_arg(args, "exclude"), + ) + +def _prepare_init(args: argparse.Namespace) -> _InitPreparation: + """Pull Request の文脈を取り、作業ディレクトリを用意して `--scope` の関門を通す。""" # リポジトリ名は git の設定から求め、Pull Request の応答で確かめる(#271)。 repo, base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) if is_own_pr: @@ -402,42 +456,73 @@ def cmd_init(args: argparse.Namespace) -> None: tmp_dir = tmp_dir_for(work) tmp_dir.mkdir(parents=True, exist_ok=True) - state_file = state_path(tmp_dir, args.pr) - - if state_file.exists(): - state = statefile.load(state_file) - if state.get("final") is None: - _resume(state_file, state, args, model_spec, include, exclude, is_own_pr) - return + return _InitPreparation( + repo=repo, + base_branch=base_branch, + head_branch=head_branch, + is_own_pr=is_own_pr, + root=root, + work=work, + tmp_dir=tmp_dir, + state_file=state_path(tmp_dir, args.pr), + round_test=round_test, + ) - for key, value in NEW_RUN_DEFAULTS.items(): - if getattr(args, key, None) is None: - setattr(args, key, value) +def _resume_if_pending( + args: argparse.Namespace, inputs: _InitInputs, prep: _InitPreparation +) -> bool: + """終わっていない前回の状態があれば再開し、`True` を返す。""" + if not prep.state_file.exists(): + return False + state = statefile.load(prep.state_file) + if state.get("final") is not None: + return False + _resume(prep.state_file, state, args, inputs.model_spec, + inputs.include, inputs.exclude, prep.is_own_pr) + return True + + +def _verify_init( + args: argparse.Namespace, inputs: _InitInputs, prep: _InitPreparation +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + """参加者を確定し、着手前のテストと範囲のテストを実行する。""" # **確認は着手前のテストより先に行う。** 使える者がいなければ、テストに時間を # 使わずに止める。 participants = resolve_participants( - host, include or [], exclude or [], bool(getattr(args, "require_all", None))) - _warn_unmeasurable_models(model_spec, participants["available"]) + inputs.host, inputs.include or [], inputs.exclude or [], + bool(getattr(args, "require_all", None))) + _warn_unmeasurable_models(inputs.model_spec, participants["available"]) - hint = round_test_hint(round_test, args.baseline_test, args.scope, str(work)) + hint = round_test_hint(prep.round_test, args.baseline_test, args.scope, str(prep.work)) if hint: info(hint) - baseline = _run_baseline_test(args.baseline_test, work, args.test_timeout) - round_record = _run_round_test(round_test, baseline, work, args.test_timeout) + baseline = _run_baseline_test(args.baseline_test, prep.work, args.test_timeout) + round_record = _run_round_test(prep.round_test, baseline, prep.work, args.test_timeout) + return participants, baseline, round_record + +def _save_initial_state( + args: argparse.Namespace, + inputs: _InitInputs, + prep: _InitPreparation, + participants: dict[str, Any], + baseline: dict[str, Any], + round_record: dict[str, Any], +) -> dict[str, Any]: + """初期の状態を組み立てて保存し、保存した状態を返す。""" context = InitialContext( - repo=repo, - base_branch=base_branch, - head_branch=head_branch, - root=root, - work=work, - tmp_dir=tmp_dir, - host=host, - detection=detection, + repo=prep.repo, + base_branch=prep.base_branch, + head_branch=prep.head_branch, + root=prep.root, + work=prep.work, + tmp_dir=prep.tmp_dir, + host=inputs.host, + detection=inputs.detection, participants=participants, - model_spec=model_spec, + model_spec=inputs.model_spec, baseline=baseline, round_test=round_record, ) @@ -445,12 +530,12 @@ def cmd_init(args: argparse.Namespace) -> None: # GitHub は自分の Pull Request への `APPROVE` と `REQUEST_CHANGES` を # `HTTP 422` で拒む。判定はそのまま結果ファイルへ残し、**投稿の event だけ** # を倒す。収束判定は結果ファイルの判定を見るので、倒しても進行は変わらない。 - _apply_post_event(state, is_own_pr) - statefile.save(state_file, state) - info(f"✅ 状態を初期化しました: {state_file}") - info(f" ホスト: {host}({detection})") + _apply_post_event(state, prep.is_own_pr) + statefile.save(prep.state_file, state) + info(f"✅ 状態を初期化しました: {prep.state_file}") + info(f" ホスト: {inputs.host}({inputs.detection})") info(f" 参加者(提案と適用): {' / '.join(state['runtimes'])}") - _emit_init(state) + return state def _resume( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index 427da7d6..67958aac 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -12,6 +12,7 @@ import signal import subprocess import time +from dataclasses import dataclass from typing import Any, Optional import models as models_lib @@ -648,10 +649,33 @@ def _pending_drop_item_ids(state: dict[str, Any], drop_ids: list[str]) -> list[s ] +@dataclass +class _DropPlan: + """取り消しと積み直しの計画。`drop_items` が組み立て、実行と記録の段へ渡す。 + + `ordered` は取り消す範囲(新しい順)、`replay` は積み直す SHA(古い順)、 + `owner` は `コミット → 改善項目 ID` の対応である。 + """ + + pending: list[str] + ordered: list[str] + owner: dict[str, str] + keep_ids: list[str] + replay: list[str] + + +def _drop_summary( + mode: str, dropped: list[str], reverted: int, replayed: int +) -> dict[str, Any]: + """取り消しの結果の形。戻り値と `entry.drops` の記録が同じ鍵を持つ。""" + return {"mode": mode, "dropped": dropped, + "reverted": reverted, "replayed": replayed} + + 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]]: +) -> _DropPlan: """残す項目 (`keep_ids`) と積み直す SHA (`replay`) を求める。 `ordered` は新しい順なので、積み直しは反転して古い順にする。 @@ -666,33 +690,30 @@ def _drop_replay_plan( 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 + return _DropPlan(pending, ordered, owner, keep_ids, replay) -def _dry_run_drop_plan( - pending: list[str], ordered: list[str], replay: list[str] -) -> dict[str, Any]: +def _dry_run_drop_plan(plan: _DropPlan) -> dict[str, Any]: """dry-run 時の出力と戻り値を作る。実際の revert/cherry-pick は行わない。""" - for sha in ordered: + for sha in plan.ordered: info(f"(dry-run)git revert --no-edit {sha}") - for sha in replay: + for sha in plan.replay: info(f"(dry-run)git cherry-pick {sha}") - return {"mode": "item", "dropped": pending, - "reverted": len(ordered), "replayed": len(replay)} + return _drop_summary("item", plan.pending, len(plan.ordered), len(plan.replay)) def _execute_drop_replay( - work: str, ordered: list[str], head: Optional[str], replay: list[str], + work: str, plan: _DropPlan, head: Optional[str], ) -> tuple[dict[str, str], str]: """範囲を取り消して残す項目を積み直す。積み直しに失敗したら round モードへ退避する。 戻り値は `(mapping, mode)`。`mapping` は積み直し後の SHA 対応 (`round` モードでは空)。 """ - _revert_range(work, ordered, head) + _revert_range(work, plan.ordered, head) # 取り消しが済んだ地点。積み直しに失敗したらここへ戻せばよい。 reverted_head = git_out(work, ["rev-parse", "HEAD"]) - mapping = _replay_commits(work, replay) + mapping = _replay_commits(work, plan.replay) if mapping is None: info("⚠ 残す項目を積み直せませんでした。このラウンドは全件取り消します") # **着手前まで戻して取り消しをやり直さない。** 同じ範囲に対する取り消しが @@ -705,38 +726,33 @@ def _execute_drop_replay( 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], + plan: _DropPlan, mapping: dict[str, str], mode: str, ) -> dict[str, Any]: """item の reverted/commits と entry.drops を更新し、結果を返す。""" scoped = scoped_item_ids(entry) - dropped = list(scoped) if mode == "round" else pending + dropped = list(scoped) if mode == "round" else plan.pending for item_id in scoped: item = find_item(state, item_id, required=False) if item is None: continue - if mode == "round" or item_id not in keep_ids: + if mode == "round" or item_id not in plan.keep_ids: item["reverted"] = True continue # **積み直しで SHA が変わる。** 記録を更新しないと、次の取り消しが # 履歴に無い SHA を指してしまう。 - item["commits"] = [mapping[s] for s in replay if owner.get(s) == item_id] + item["commits"] = [mapping[s] for s in plan.replay if plan.owner.get(s) == item_id] entry.setdefault("drops", []).append({ - "at": statefile.now(), "mode": mode, "dropped": dropped, - "reverted": len(ordered), "replayed": len(mapping), + "at": statefile.now(), + **_drop_summary(mode, dropped, len(plan.ordered), len(mapping)), }) info( - f"↩ 取り消し {len(ordered)} コミット / 積み直し {len(mapping)} コミット" + f"↩ 取り消し {len(plan.ordered)} コミット / 積み直し {len(mapping)} コミット" f"({'ラウンド全件へ退避' if mode == 'round' else '項目単位'})" ) - return {"mode": mode, "dropped": dropped, - "reverted": len(ordered), "replayed": len(mapping)} + return _drop_summary(mode, dropped, len(plan.ordered), len(mapping)) def _drop_legacy_by_item( @@ -751,8 +767,7 @@ def _drop_legacy_by_item( 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} + return _drop_summary("item", pending, reverted, 0) def drop_items( @@ -781,7 +796,7 @@ def drop_items( pending = _pending_drop_item_ids(state, drop_ids) if not pending: info("↩ 取り消し対象は取り消し済みです") - return {"mode": "skip", "dropped": [], "reverted": 0, "replayed": 0} + return _drop_summary("skip", [], 0, 0) head = git_out(work, ["rev-parse", "HEAD"]) ordered = commits_in_range(work, entry.get("apply_base_sha"), head or "HEAD") @@ -789,16 +804,14 @@ def drop_items( # 起点を記録していない状態ファイル(旧版)では積み直せない。 return _drop_legacy_by_item(state, pending, dry_run) - owner, keep_ids, replay = _drop_replay_plan(state, entry, pending, ordered) + plan = _drop_replay_plan(state, entry, pending, ordered) if dry_run: - return _dry_run_drop_plan(pending, ordered, replay) + return _dry_run_drop_plan(plan) - mapping, mode = _execute_drop_replay(work, ordered, head, replay) + mapping, mode = _execute_drop_replay(work, plan, head) - return _record_drop_result( - state, entry, pending, keep_ids, ordered, replay, owner, mapping, mode, - ) + return _record_drop_result(state, entry, plan, mapping, mode) def _order_newest_first(work: str, shas: list[str]) -> list[str]: diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py index 2a466667..0cfd403e 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py @@ -11,6 +11,7 @@ import re from collections import Counter +from dataclasses import dataclass from typing import Any, Iterable, Optional @@ -262,49 +263,83 @@ def verify_apply_round( `tracked_md` は追跡している `.md` の一覧(`tracked_markdown()`)、`work` は 補助モジュールを git から読む作業ディレクトリである(`doc_wording_tests`)。 """ - if not facts: + context = _ApplyRoundContext(items, facts, scope, work, tracked_md) + for stage in _APPLY_ROUND_STAGES: + problem = stage(context) + if problem: + return problem + return None + + +@dataclass +class _ApplyRoundContext: + """`verify_apply_round` の各段が受け取る入力。""" + + items: list[dict[str, Any]] + facts: list[dict[str, Any]] + scope: Optional[Iterable[str]] + work: Optional[str] + tracked_md: Iterable[str] + + +def _apply_round_basics(context: _ApplyRoundContext) -> Optional[str]: + """コミットの実在と、各コミットの基礎検査。""" + if not context.facts: return ( "コミットが 1 件もありません" "(適用ラウンド = 1 コミットの前提を満たしていません)" ) - for commit in facts: + for commit in context.facts: problem = _verify_commit_basics( commit, - scope, + context.scope, f"コミット {commit.get('sha', '?')} が base..head の範囲にありません" "(申告だけで実体がありません)", check_test=False, ) if problem: return problem + return None + - problem = _verify_test_gap_present(items, facts) +def _apply_round_test_protection(context: _ApplyRoundContext) -> Optional[str]: + """現状固定テストの有無と、テストの期待値の変更。""" + problem = _verify_test_gap_present(context.items, context.facts) if problem: return problem # **テストの期待値が変わっていないか**(#443)。段 1(機械)で決まるものだけを # ここで落とす。決まらないものは `pending_test_judgements` が集め、進行側が # 段 2(AI エージェント)へ渡す。 - changes = collect_test_changes(facts) - problem = verify_test_changes(changes) - if problem: - return problem + changes = collect_test_changes(context.facts) + return verify_test_changes(changes) + +def _apply_round_diff_constraints(context: _ApplyRoundContext) -> Optional[str]: + """文言固定テスト・差分予算・コミット粒度。""" # **文書の文言を固定するテストを足していないか**(#723)。 - hits = doc_wording_tests(facts, tracked_md, work) + hits = doc_wording_tests(context.facts, context.tracked_md, context.work) if hits: return ( "文書の文言を固定するテストは足さない" f"({'、'.join(f'{path}: {literal}' for path, literal in hits)})" ) - problem = _verify_diff_budget(items, facts) + problem = _verify_diff_budget(context.items, context.facts) if problem: return problem # 粒度は最後に見る。トレーラーや範囲の問題を粒度の失敗で覆い隠さない。 - return _verify_apply_commit_count(facts) + return _verify_apply_commit_count(context.facts) + + +# 検査の順序そのものが規則である。先の段の問題を後の段の失敗で覆い隠さない。 +_APPLY_ROUND_STAGES = ( + _apply_round_basics, + _apply_round_test_protection, + _apply_round_diff_constraints, +) def commit_limit_for(item: dict[str, Any]) -> int: diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_drop_items_git.py b/plugins/ndf/skills/cross-refactoring/tests/test_drop_items_git.py index a2e9e12b..67695b23 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_drop_items_git.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_drop_items_git.py @@ -226,3 +226,32 @@ def built_commits_still_reachable(built) -> bool: """着手前のコミットが履歴から消えていないこと。""" log = _git("rev-list", "HEAD", cwd=built["repo"]).stdout.split() return built["c1"] in log and built["base"] in log + + +# ---------- 現状固定: dry-run と旧版の状態ファイル ---------- + +def test_dry_run_reports_the_plan_without_touching_history(gitfacts, distant_repo): + """dry-run は取り消しと積み直しの件数だけを返し、履歴も状態も変えないこと。""" + state, entry = _state(distant_repo) + head = _git("rev-parse", "HEAD", cwd=distant_repo["repo"]).stdout.strip() + + result = gitfacts.drop_items(state, entry, ["R1-001"], dry_run=True) + + assert result == {"mode": "item", "dropped": ["R1-001"], + "reverted": 2, "replayed": 1} + assert _git("rev-parse", "HEAD", cwd=distant_repo["repo"]).stdout.strip() == head + assert "drops" not in entry + assert not any(i.get("reverted") for i in state["items"]) + + +def test_legacy_state_reverts_only_the_item_commits(gitfacts, distant_repo): + """起点(`apply_base_sha`)の無い旧版では、項目のコミットだけを戻すこと。""" + state, entry = _state(distant_repo) + del entry["apply_base_sha"] + + result = gitfacts.drop_items(state, entry, ["R1-002"]) + + assert result == {"mode": "item", "dropped": ["R1-002"], + "reverted": 1, "replayed": 0} + assert "line3-by-R1-001" in _content(distant_repo) + assert "line31-by-R1-002" not in _content(distant_repo) From 0e5ed676dcfb5c590943ffbc84c934f05f29c9f7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 15:25:40 +0000 Subject: [PATCH 12/19] =?UTF-8?q?Refactor:=20=E6=8F=90=E6=A1=88=E3=83=A9?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E3=83=89=E5=87=A6=E7=90=86=E3=82=92=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=E3=81=97=E3=81=A6=E8=A6=8B=E9=80=81=E3=82=8A=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=82=92=E5=85=B1=E9=80=9A=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ラウンド生成と表示条件、提案統合の各段階を抽出し、取り消した項目の見送り記録を共通ヘルパーへ集約する。 Item-Id: R4-001 Round: 4 Impl-Runtime: codex Impl-Model: default --- .../scripts/refactor_lib/commands/apply.py | 117 +++++++++++------- .../scripts/refactor_lib/commands/converge.py | 11 +- .../scripts/refactor_lib/commands/setup.py | 57 +++++---- .../scripts/refactor_lib/rounds.py | 15 +++ 4 files changed, 124 insertions(+), 76 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index 8cfa299d..70df83f0 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -42,10 +42,10 @@ from ..proposals import assign_apply_rounds, merge_proposals, merge_test_proposals from ..rounds import ( TEST, + append_deferred_abandoned_items, apply_groups, attempt_of, current_group, - deferred_record, entry_kind, group_reopening, impl_for_seq, @@ -213,6 +213,66 @@ def _update_state_from_merged_proposals( statefile.save(path, state) +def _replay_merged_proposals( + state: dict[str, Any], entry: dict[str, Any], kind: str +) -> bool: + """統合済みラウンドの結果を再表示し、処理済みかを返す。""" + if entry.get("proposal_keys") is None: + return False + 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_label(item)}") + if not entry.get("adopted") and kind != TEST: + sys.exit(2) + return True + + +def _merge_proposals_for_kind( + state: dict[str, Any], entry: dict[str, Any], kind: str, + proposals: dict[str, list[dict[str, Any]]], excluded: set[tuple[str, ...]], +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """ラウンドの種類に対応する方法で提案を統合する。""" + if kind == TEST: + return merge_test_proposals( + proposals, + max_items=state["max_items_per_round"], + excluded_keys=excluded, + ) + return merge_proposals( + proposals, + threshold=state["severity_threshold"], + max_items=state["max_items_per_round"], + excluded_keys=excluded, + ) + + +def _report_merged_proposals( + state: dict[str, Any], entry: dict[str, Any], kind: str, + adopted: list[dict[str, Any]], +) -> None: + """統合結果を表示し、採用 0 件なら種類に応じて終了を制御する。""" + info( + f"提案 {sum(entry['proposed'].values())} 件 → 統合 {entry['merged']} 件 → " + f"採用 {entry['adopted']} 件 / 見送り {entry['deferred']} 件" + ) + for item_id in entry["items"]: + info(f" {item_id} {_item_summary(find_item(state, item_id))}") + if adopted: + return + if kind == TEST: + # **終了ではない。** 足すべきテストが出なくなっただけで、この後に + # 構造改善の提案ラウンドが続く。切り替えは `advance` が行う。 + info("テスト整備の採用 0 件のため、構造改善の提案ラウンドへ進みます") + return + info("採用 0 件のため、提案ラウンドの繰り返しを終えます") + sys.exit(2) + + def cmd_merge_proposals(args: argparse.Namespace) -> None: """Step 3 — 提案をマージして改善項目を作る。 @@ -225,51 +285,17 @@ def cmd_merge_proposals(args: argparse.Namespace) -> None: entry = current_round(state) kind = entry_kind(entry) - 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_label(item)}") - if not entry.get("adopted") and kind != TEST: - sys.exit(2) + if _replay_merged_proposals(state, entry, kind): return proposals = _load_runtime_proposals(state, entry) excluded = {item_key(d) for d in state["deferred_items"]} - if kind == TEST: - adopted, deferred = merge_test_proposals( - proposals, - max_items=state["max_items_per_round"], - excluded_keys=excluded, - ) - else: - adopted, deferred = merge_proposals( - proposals, - threshold=state["severity_threshold"], - max_items=state["max_items_per_round"], - excluded_keys=excluded, - ) + adopted, deferred = _merge_proposals_for_kind( + state, entry, kind, proposals, excluded) _update_state_from_merged_proposals(path, state, entry, adopted, deferred) - info( - f"提案 {sum(entry['proposed'].values())} 件 → 統合 {entry['merged']} 件 → " - f"採用 {entry['adopted']} 件 / 見送り {entry['deferred']} 件" - ) - for item_id in entry["items"]: - info(f" {item_id} {_item_summary(find_item(state, item_id))}") - if not adopted: - if kind == TEST: - # **終了ではない。** 足すべきテストが出なくなっただけで、この後に - # 構造改善の提案ラウンドが続く。切り替えは `advance` が行う。 - info("テスト整備の採用 0 件のため、構造改善の提案ラウンドへ進みます") - return - info("採用 0 件のため、提案ラウンドの繰り返しを終えます") - sys.exit(2) + _report_merged_proposals(state, entry, kind, adopted) def _item_summary(item: dict[str, Any]) -> str: @@ -968,15 +994,13 @@ def _defer_abandoned_items(state: dict[str, Any], group: dict[str, Any]) -> None 除外の鍵は種類で変わる(改善項目は `path` + `symbol` + `smell`、テスト項目は `target` + `case`)。記録の形は `rounds.deferred_record` が持つ。 """ - already = {d.get("item_id") for d in state["deferred_items"]} + items = [] for item_id in group["items"]: item = find_item(state, item_id, required=False) - if item is None or item.get("status") != "abandoned" or item_id in already: - continue - state["deferred_items"].append(deferred_record( - item, item_id, - item.get("failure_reason") or "適用結果の検証を通らなかった", - )) + if item is not None and item.get("status") == "abandoned": + items.append(item) + append_deferred_abandoned_items( + state, items, "適用結果の検証を通らなかった") @@ -1159,4 +1183,3 @@ def cmd_merge_test_judgements(args: argparse.Namespace) -> None: path, state, entry, current_group(entry), outcome["problem"]) _apply_group_judgements(path, state, entry, group_of_round, verdicts) - diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index 8cd4137d..a94a7584 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -42,8 +42,8 @@ from ..outbound import dropped_line, item_lines, plan_line from ..paths import git_out, load_state, result_path, stem_for from ..rounds import ( + append_deferred_abandoned_items, current_group, - deferred_record, phase_after_group, prepare_fix_phase, ) @@ -197,16 +197,15 @@ def _record_deferred_abandoned_items( state: dict[str, Any], targets: list[str] ) -> None: """取り消し対象項目の status を abandoned に更新し、未登録なら deferred_items に追記する。""" - already = {d.get("item_id") for d in state["deferred_items"]} + items = [] for item_id in targets: item = find_item(state, item_id) item["status"] = "abandoned" item.setdefault( "failure_reason", "修正ラウンドの上限に達してもテストが通らなかった") - if item_id in already: - continue - state["deferred_items"].append( - deferred_record(item, item_id, item["failure_reason"])) + items.append(item) + append_deferred_abandoned_items( + state, items, "修正ラウンドの上限に達してもテストが通らなかった") def cmd_abandon_items(args: argparse.Namespace) -> None: diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index 84c98cf1..8781201a 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -761,6 +761,38 @@ def rounds_of_kind(state: dict[str, Any], kind: str) -> list[dict[str, Any]]: return [r for r in state.get("rounds") or [] if entry_kind(r) == kind] +def _new_round_entry( + state: dict[str, Any], round_no: int, kind: str +) -> dict[str, Any]: + """新しいラウンドの記録を組み立てる。""" + impl, requested = impl_for_seq(state, round_no) + return { + "round": round_no, + # **種類はラウンドごとに残す。** 上限を別々に数えるためと、提案の + # 重複率を同じ種類どうしで測るためである。 + "kind": kind, + "started_at": statefile.now(), + "impl": impl, + "impl_model": {"requested": requested, "observed": None}, + "proposed": {}, + "merged": 0, "adopted": 0, "deferred": 0, + "items": [], + "apply": {"applied": [], "failed": [], "base_sha": None, "head_sha": None}, + "fix_rounds": 0, + "durations": {}, + "reviews": [], + } + + +def _round_label_and_limit( + state: dict[str, Any], kind: str +) -> tuple[str, Optional[int]]: + """ラウンドの種類に対応する表示名と上限を返す。""" + if kind == TEST: + return "テスト整備ラウンド", state.get("max_test_rounds") + return "提案ラウンド", state["max_outer_rounds"] + + def cmd_start_round(args: argparse.Namespace) -> None: """Step 2 — ラウンドを開き、実装担当を返す。 @@ -790,35 +822,14 @@ def cmd_start_round(args: argparse.Namespace) -> None: round_no = len(rounds) + 1 existing = next((r for r in rounds if r["round"] == round_no), None) if existing is None: - impl, requested = impl_for_seq(state, round_no) - existing = { - "round": round_no, - # **種類はラウンドごとに残す。** 上限を別々に数えるためと、提案の - # 重複率を同じ種類どうしで測るためである。 - "kind": kind, - "started_at": statefile.now(), - "impl": impl, - "impl_model": {"requested": requested, "observed": None}, - "proposed": {}, - "merged": 0, "adopted": 0, "deferred": 0, - "items": [], - "apply": {"applied": [], "failed": [], "base_sha": None, "head_sha": None}, - "fix_rounds": 0, - "durations": {}, - "reviews": [], - } + existing = _new_round_entry(state, round_no, kind) rounds.append(existing) state["outer_round"] = round_no state["phase"] = "propose" statefile.save(path, state) kind = entry_kind(existing) - if kind == TEST: - label = "テスト整備ラウンド" - limit = state.get("max_test_rounds") - else: - label = "提案ラウンド" - limit = state["max_outer_rounds"] + label, limit = _round_label_and_limit(state, kind) seq = len(rounds_of_kind(state, kind)) info( f"=== {label} {seq} / {limit} " diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py index 0b4c825c..cb80f8ce 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py @@ -87,6 +87,21 @@ def deferred_record( record.update({"symbol": item.get("symbol"), "smell": item.get("smell")}) return record + +def append_deferred_abandoned_items( + state: dict[str, Any], items: list[dict[str, Any]], default_reason: str +) -> None: + """abandoned の項目を未登録時だけ見送り記録へ追記する。""" + already = {d.get("item_id") for d in state["deferred_items"]} + for item in items: + item_id = item["item_id"] + if item_id in already: + continue + reason = item.get("failure_reason") or default_reason + state["deferred_items"].append(deferred_record(item, item_id, reason)) + already.add(item_id) + + def finish_outer_rounds(path: pathlib.Path, state: dict[str, Any], reason: str) -> None: state["final"] = reason state["ended_at"] = statefile.now() From 04791b457b244cb2449a0aa5e6cdb02990b84881 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 15:30:19 +0000 Subject: [PATCH 13/19] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20converge.py#cmd=5Fabandon=5Fitems=20/=20apply.py#=5Fapply=5F?= =?UTF-8?q?drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_abandon_items をやり残しの後片づけ・対象なし処理・完了処理の 3 つの ヘルパーへ抽出し、本体は各段階の呼び出し順だけを表す形にした。_apply_drop を 取り消しと採用状態の反映・状態確定・永続化の 3 段のヘルパーへ抽出した。 振る舞いは不変で、既存テストが通る。 Item-Id: R4-003 Round: 4 Impl-Runtime: kiro Impl-Model: default --- .../scripts/refactor_lib/commands/apply.py | 70 ++++++++++----- .../scripts/refactor_lib/commands/converge.py | 85 ++++++++++++------- 2 files changed, 107 insertions(+), 48 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index 70df83f0..59928261 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -1005,26 +1005,15 @@ def _defer_abandoned_items(state: dict[str, Any], group: dict[str, Any]) -> None -def _apply_drop( +def _drop_and_settle_adoption( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], group: dict[str, Any], failed: list[str], ) -> list[str]: - """検証に失敗した適用ラウンドを取り消し、採用として残る項目 ID を返す。 - - **中断しても再開できる形で記録する。** 失敗の位置で必要な再開が変わるため、 - 印は次の順で切り替える。 - - | 中断した位置 | 残る印 | 次の実行がすること | - | --- | --- | --- | - | 取り消しの途中 | `pending_drop` あり / `merged_at` なし | 取り消しをやり直す | - | 取り消し後・push 前 | `pending_drop` なし / `merged_at` あり / `pending_push` あり | **push の再送だけ** | + """取り消しを実行し、item・entry・group の採用状態を反映して採用 ID を返す。 - 取り消しより先に `merged_at` を立てると、取り消しに失敗したときに次の実行が - 処理済みガードで素通りし、**再試行できない**。逆に push まで終えるまで - `merged_at` を立てないと、push だけ失敗したときに次の実行が適用の検証をやり直し、 - 取り消しと積み直しのコミットを「未割当」と判定して群ごと巻き込む。 + `run_drop` が積み直せなかった(`mode=round`)ときは、この群の全件を捨てる。 + **他の群には及ばない。** """ - work = state["worktrees"]["work"] result = run_drop(path, state, entry, failed) applied = list(entry["apply"].get("applied") or []) if result["mode"] == "round": @@ -1039,6 +1028,14 @@ def _apply_drop( applied = [] entry["apply"]["applied"] = [] entry["apply"]["failed"] = list(group["items"]) + return applied + + +def _settle_drop_state( + state: dict[str, Any], entry: dict[str, Any], group: dict[str, Any], + work: pathlib.Path, applied: list[str], +) -> None: + """取り消し後の起点・群の状態・次 phase を確定する。""" if not applied: # 取り消し後の状態を新しい起点にする(叩き直しでの二重取り消しを防ぐ)。 entry["apply_base_sha"] = git_out(work, ["rev-parse", "HEAD"]) @@ -1048,18 +1045,53 @@ def _apply_drop( group["status"] = "applied" state["phase"] = phase_after_group(entry) + +def _persist_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + group: dict[str, Any], +) -> None: + """deferred 記録・pending_drop 解除・merged_at 設定・push を永続化する。 + + **取り消しが済んだことを push より先に、印の解除と同じ保存で永続化する。** + 保存せずに push して失敗すると、次の実行が適用の検証をやり直し、取り消しと + 積み直しのコミットを「未割当」と判定して群ごと巻き込んでしまう。 + `pending_push` は残るので、次の実行は push の再送だけを行う。 + """ # 取り消した項目は「対象外」として残す。次のラウンドで同じ提案が採用され、 # 同じ理由で失敗するのを防ぐ。 _defer_abandoned_items(state, group) - # **取り消しが済んだことを push より先に、印の解除と同じ保存で永続化する。** - # 保存せずに push して失敗すると、次の実行が適用の検証をやり直し、取り消しと - # 積み直しのコミットを「未割当」と判定して群ごと巻き込んでしまう。 - # `pending_push` は残るので、次の実行は push の再送だけを行う。 entry["pending_drop"] = [] entry["apply"]["merged_at"] = statefile.now() # 印は `run_drop` が立ててある。ここは保存・push・印の解除を行う # (`push_with_retry_marker` が立て直しても値は変わらない)。 push_with_retry_marker(path, state, entry) + + +def _apply_drop( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + group: dict[str, Any], failed: list[str], +) -> list[str]: + """検証に失敗した適用ラウンドを取り消し、採用として残る項目 ID を返す。 + + **中断しても再開できる形で記録する。** 失敗の位置で必要な再開が変わるため、 + 印は次の順で切り替える。 + + | 中断した位置 | 残る印 | 次の実行がすること | + | --- | --- | --- | + | 取り消しの途中 | `pending_drop` あり / `merged_at` なし | 取り消しをやり直す | + | 取り消し後・push 前 | `pending_drop` なし / `merged_at` あり / `pending_push` あり | **push の再送だけ** | + + 取り消しより先に `merged_at` を立てると、取り消しに失敗したときに次の実行が + 処理済みガードで素通りし、**再試行できない**。逆に push まで終えるまで + `merged_at` を立てないと、push だけ失敗したときに次の実行が適用の検証をやり直し、 + 取り消しと積み直しのコミットを「未割当」と判定して群ごと巻き込む。 + + 取り消し、状態の反映、永続化の 3 段に分ける。 + """ + work = state["worktrees"]["work"] + applied = _drop_and_settle_adoption(path, state, entry, group, failed) + _settle_drop_state(state, entry, group, work, applied) + _persist_drop(path, state, entry, group) return applied diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index a94a7584..aae4670a 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -208,6 +208,59 @@ def _record_deferred_abandoned_items( state, items, "修正ラウンドの上限に達してもテストが通らなかった") +def _resume_abandon_leftovers( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any] +) -> None: + """やり残した取り消しを push の再送より先に片づける。 + + 先に push すると、取り消しが途中の HEAD をそのまま Pull Request へ反映して + しまう。取り消しが残っていれば再実行し、無ければ保留の push だけ流す。 + """ + if entry.get("pending_drop"): + info("↻ 前回終わらなかった取り消しを再実行します") + run_drop(path, state, entry, list(entry["pending_drop"])) + else: + flush_pending_push(path, state, entry) + + +def _abandon_no_targets( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + group: dict[str, Any], dry_run: bool, +) -> None: + """取り消す項目が無い群を処理済みとして記録する。""" + info("取り消す項目はありません") + if not dry_run: + group["abandoned"] = [] + entry["abandoned"] = [] + statefile.save(path, state) + + +def _complete_abandon( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + group: dict[str, Any], targets: list[str], +) -> None: + """取り消しを実行し、見送りの記録・状態の更新・push を行う。 + + 見送りの記録と印の解除を**同じ保存で**行う。保存してから push するので、 + push が失敗しても記録とローカルの git が食い違わない。 + """ + run_drop(path, state, entry, targets) + + _record_deferred_abandoned_items(state, targets) + + # **内訳は書かない。件数だけ述べ、内訳は改修計画へ譲る**(#436 決定 6-b)。 + info(f"↩ 適用ラウンド {group['apply_round']}: {dropped_line(state, len(targets))}") + group["abandoned"] = targets + group["status"] = "dropped" + entry["abandoned"] = targets + entry["pending_drop"] = [] + entry["apply_base_sha"] = git_out( + state["worktrees"]["work"], ["rev-parse", "HEAD"]) + group["base_sha"] = entry["apply_base_sha"] + state["phase"] = phase_after_group(entry) + push_with_retry_marker(path, state, entry) + + def cmd_abandon_items(args: argparse.Namespace) -> None: """Step 6 — テストが通らなかった適用ラウンドを取り消す。 @@ -219,13 +272,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: entry = round_of(state, args.round) group = current_group(entry) if not args.dry_run: - # **やり残した取り消しを push の再送より先に片づける。** 先に push すると、 - # 取り消しが途中の HEAD をそのまま Pull Request へ反映してしまう。 - if entry.get("pending_drop"): - info("↻ 前回終わらなかった取り消しを再実行します") - run_drop(path, state, entry, list(entry["pending_drop"])) - else: - flush_pending_push(path, state, entry) + _resume_abandon_leftovers(path, state, entry) # 取り消し自体は `reverted` で冪等だが、見送りの記録は重複しうる。 if group.get("abandoned") is not None: @@ -235,11 +282,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: targets = list((entry.get("apply") or {}).get("applied") or []) if not targets: - info("取り消す項目はありません") - if not args.dry_run: - group["abandoned"] = [] - entry["abandoned"] = [] - statefile.save(path, state) + _abandon_no_targets(path, state, entry, group, args.dry_run) return if args.dry_run: @@ -247,23 +290,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: info("(dry-run)状態ファイルは更新していません") return - run_drop(path, state, entry, targets) - - _record_deferred_abandoned_items(state, targets) - - # 見送りの記録と印の解除を**同じ保存で**行う。保存してから push するので、 - # push が失敗しても記録とローカルの git が食い違わない。 - # **内訳は書かない。件数だけ述べ、内訳は改修計画へ譲る**(#436 決定 6-b)。 - info(f"↩ 適用ラウンド {group['apply_round']}: {dropped_line(state, len(targets))}") - group["abandoned"] = targets - group["status"] = "dropped" - entry["abandoned"] = targets - entry["pending_drop"] = [] - entry["apply_base_sha"] = git_out( - state["worktrees"]["work"], ["rev-parse", "HEAD"]) - group["base_sha"] = entry["apply_base_sha"] - state["phase"] = phase_after_group(entry) - push_with_retry_marker(path, state, entry) + _complete_abandon(path, state, entry, group, targets) def _fix_merge_key(entry: dict[str, Any], result: pathlib.Path) -> str: From 6b884626b4d949ec64228b9d6290d98389dbb367 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 15:38:31 +0000 Subject: [PATCH 14/19] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/skills/cross-refactoring/scripts/refactor=5Flib/?= =?UTF-8?q?commands/gate.py#cmd=5Ffinal=5Fgate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提案ラウンド 5 の適用ラウンド 1(振る舞いは不変)。 - R5-001: cmd_final_gate から検査の選択・計時・記録を _run_and_record_gate_check へ抽出 - R5-002: 修正取り込みの path・state・entry・scope・work を FixExecutionContext にまとめる - R5-003: setup.py#rounds_of_kind と report.py#_of_kind を rounds.py#rounds_of_kind へ統合 - R5-004: _update_state_from_merged_proposals から次の局面の決定を _next_phase_after_merge へ抽出 - R5-005: baseline_search_roots と round_test_roots の共通の骨組みを _scope_roots へ抽出 Item-Id: R5-001 Round: 5 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5.5 --- .../scripts/refactor_lib/commands/apply.py | 9 ++- .../scripts/refactor_lib/commands/converge.py | 72 ++++++++++--------- .../scripts/refactor_lib/commands/gate.py | 34 +++++---- .../scripts/refactor_lib/commands/report.py | 13 ++-- .../scripts/refactor_lib/commands/setup.py | 10 +-- .../scripts/refactor_lib/rounds.py | 5 ++ .../scripts/refactor_lib/scope.py | 51 ++++++------- 7 files changed, 106 insertions(+), 88 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index 59928261..a2e58232 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -198,6 +198,14 @@ def _update_state_from_merged_proposals( # 提案は読むだけなので、この時点の HEAD が着手前の状態である。 entry["apply_base_sha"] = git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + _next_phase_after_merge(state, entry, adopted) + statefile.save(path, state) + + +def _next_phase_after_merge( + state: dict[str, Any], entry: dict[str, Any], adopted: list[dict[str, Any]] +) -> None: + """統合の結果から次の局面を決める。収束したときは終了理由も確定させる。""" if adopted: state["phase"] = "apply" elif entry_kind(entry) == TEST: @@ -210,7 +218,6 @@ def _update_state_from_merged_proposals( # 終了理由をここで確定させないと、報告が「未終了」のままになる。 state["final"] = "no_more_proposals" state["ended_at"] = statefile.now() - statefile.save(path, state) def _replay_merged_proposals( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index aae4670a..87e3b0f3 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -12,6 +12,7 @@ import pathlib import sys import time +from dataclasses import dataclass from typing import Any, Optional import statefile @@ -432,26 +433,32 @@ def _revert_invalid_fix_round( return set() -def _resolve_fix_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - work: str, - head_now: str, -) -> list[str]: +@dataclass +class FixExecutionContext: + """修正の取り込み 1 回分に属する値の組。""" + + path: pathlib.Path + state: dict[str, Any] + entry: dict[str, Any] + scope: IntakeScope + work: str + + +def _resolve_fix_range(ctx: FixExecutionContext, head_now: str) -> list[str]: """修正の範囲を**オーケストレータが記録した起点**から確定して返す。 起点は `verify-round` がテストの失敗を返したときの HEAD である。確定できない ときは修正ラウンドを 1 つ進めて保存したうえで `die` する。 """ - ordered_range = commits_in_range(work, entry.get("fix_base_sha"), head_now) + entry = ctx.entry + ordered_range = commits_in_range(ctx.work, entry.get("fix_base_sha"), head_now) if ordered_range is None: # **修正ラウンドは進める。** 進めないと `should-abandon` が見送りへ移る # 条件(`fix_rounds` が上限に達する)を永久に満たさず、修正フェーズと # 再レビューを無限に往復する。この修正は採らないので、範囲外の記録は # 何も足さない。 entry["fix_rounds"] += 1 - statefile.save(path, state) + statefile.save(ctx.path, ctx.state) die( "修正の範囲を確定できませんでした" f"(起点 {entry.get('fix_base_sha')} / HEAD {head_now})。" @@ -494,10 +501,7 @@ def _inspect_fix_commits( def _settle_fix_round( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - scope: IntakeScope, + ctx: FixExecutionContext, ordered_range: list[str], resolved: set[str], unassigned: list[str], @@ -506,11 +510,13 @@ def _settle_fix_round( ) -> None: """検証結果に応じて修正ラウンドを取り消すか受理し、解決の印を付ける。""" if unassigned or problems: - resolved = _revert_invalid_fix_round(path, state, scope, ordered_range) + resolved = _revert_invalid_fix_round( + ctx.path, ctx.state, ctx.scope, ordered_range + ) else: - _record_accepted_fix_commits(state, accepted) + _record_accepted_fix_commits(ctx.state, accepted) - _mark_resolved_fix_findings(entry, resolved) + _mark_resolved_fix_findings(ctx.entry, resolved) def _fix_scope(entry: dict[str, Any], impl: str) -> IntakeScope: @@ -563,17 +569,17 @@ def _close_failed_fix( def _fetch_fix_result( - path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - scope: IntakeScope, impl: str, round_no: int, + ctx: FixExecutionContext, impl: str, round_no: int, ) -> tuple[Optional[dict[str, Any]], Optional[str]]: """修正結果を取得し、`(payload, merge_key)` を返す。 結果を残さなかった試行は `_close_failed_fix` が終了させる。取り込み済みの 結果なら `(None, None)` を返し、呼び出し側が何もせず戻れるようにする。 """ + state, entry = ctx.state, ctx.entry outcome = read_result(state, impl, "fix", round_no) if outcome.payload is None: - _close_failed_fix(path, state, entry, scope, outcome) + _close_failed_fix(ctx.path, state, entry, ctx.scope, outcome) payload = outcome.payload result = result_path(state, impl, stem_for(impl, "fix", state["id"], round_no)) @@ -584,30 +590,30 @@ def _fetch_fix_result( def _confirm_and_settle_fix( - path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], - scope: IntakeScope, work: str, head_now: str, payload: dict[str, Any], + ctx: FixExecutionContext, head_now: str, payload: dict[str, Any], ) -> set[str]: """Git 範囲を確定し、修正コミットを検証して取り消すか受理する。 採用した解決スレッドの集合を返す(取り込みの通知に使う)。 """ + state = ctx.state resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) - ordered_range = _resolve_fix_range(path, state, entry, work, head_now) + ordered_range = _resolve_fix_range(ctx, head_now) unassigned, problems, accepted = _inspect_fix_commits( - state, work, payload, ordered_range + state, ctx.work, payload, ordered_range ) _settle_fix_round( - path, state, entry, scope, ordered_range, resolved, unassigned, problems, - accepted, + ctx, ordered_range, resolved, unassigned, problems, accepted, ) return resolved def _record_and_publish_fix( - path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + ctx: FixExecutionContext, merge_key: str, payload: dict[str, Any], resolved: set[str], ) -> None: """取り込み済みの鍵・修正回数・所要時間を記録し、保存して公開する。""" + path, state, entry = ctx.path, ctx.state, ctx.entry entry["fix_merged_keys"].append(merge_key) entry["fix_rounds"] += 1 entry.setdefault("durations", {})["fix"] = ( @@ -643,15 +649,13 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: info("↻ この修正の試行は結果なしとして記録済みです") sys.exit(2) - payload, merge_key = _fetch_fix_result( - path, state, entry, scope, impl, args.round + ctx = FixExecutionContext( + path, state, entry, scope, state["worktrees"]["work"] ) + payload, merge_key = _fetch_fix_result(ctx, impl, args.round) if payload is None: return - work = state["worktrees"]["work"] - head_now = git_out(work, ["rev-parse", "HEAD"]) or "" - resolved = _confirm_and_settle_fix( - path, state, entry, scope, work, head_now, payload - ) - _record_and_publish_fix(path, state, entry, merge_key, payload, resolved) + head_now = git_out(ctx.work, ["rev-parse", "HEAD"]) or "" + resolved = _confirm_and_settle_fix(ctx, head_now, payload) + _record_and_publish_fix(ctx, merge_key, payload, resolved) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index 26eb96a1..46df2082 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -72,19 +72,7 @@ def cmd_final_gate(args: argparse.Namespace) -> None: ) return - # **排他である。** `--ci-check` があれば手元のテストを実行せず継続的統合の成功 - # だけで判定し、無ければ手元のテストだけで判定する。「どちらか一方が通れば通過」 - # とはしない(OR で採ると、手元のテストの失敗を継続的統合の成功が覆す)。 - ci_check = str(state.get("ci_check") or "").strip() - gate["mode"] = "ci" if ci_check else "test" - started = time.monotonic() - passed, detail = ( - _ci_gate(state, ci_check) if ci_check else _local_gate(state) - ) - _record_gate_check( - gate, ci_check or _baseline_command(state), passed, detail, - round(time.monotonic() - started, 1), - ) + passed, detail = _run_and_record_gate_check(state, gate) if passed and standalone: _emit_cross_review( @@ -102,6 +90,26 @@ def cmd_final_gate(args: argparse.Namespace) -> None: _gate_failing(path, state, gate, detail, limit) +def _run_and_record_gate_check( + state: dict[str, Any], gate: dict[str, Any] +) -> tuple[bool, str]: + """最終ゲートの検査を 1 回走らせ、`checks` へ記録して結果を返す。""" + # **排他である。** `--ci-check` があれば手元のテストを実行せず継続的統合の成功 + # だけで判定し、無ければ手元のテストだけで判定する。「どちらか一方が通れば通過」 + # とはしない(OR で採ると、手元のテストの失敗を継続的統合の成功が覆す)。 + ci_check = str(state.get("ci_check") or "").strip() + gate["mode"] = "ci" if ci_check else "test" + started = time.monotonic() + passed, detail = ( + _ci_gate(state, ci_check) if ci_check else _local_gate(state) + ) + _record_gate_check( + gate, ci_check or _baseline_command(state), passed, detail, + round(time.monotonic() - started, 1), + ) + return passed, detail + + def _emit_cross_review( path: pathlib.Path, state: dict[str, Any], gate: dict[str, Any], message: str ) -> None: diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py index c6dd584c..de4c6528 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py @@ -20,7 +20,7 @@ from ..outbound import plan_reference from ..paths import load_state from ..proposals import duplicate_rate -from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, item_kind, item_label +from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, item_kind, item_label, rounds_of_kind from ..vocabulary import DEFAULT_MAX_TEST_ROUNDS, DUPLICATE_RATE_THRESHOLD @@ -47,13 +47,13 @@ def cmd_advance(args: argparse.Namespace) -> None: if entry_kind(last) == TEST: _advance_test_rounds(path, state, last) return - if len(_of_kind(rounds, STRUCTURE)) >= state["max_outer_rounds"]: + if len(rounds_of_kind(rounds, STRUCTURE)) >= state["max_outer_rounds"]: finish_outer_rounds(path, state, "max_outer_rounds") sys.exit(1) if last.get("adopted") == 0: finish_outer_rounds(path, state, "no_more_proposals") sys.exit(1) - previous = _of_kind(rounds[:-1], STRUCTURE) + previous = rounds_of_kind(rounds[:-1], STRUCTURE) if previous: # **同じ種類どうしで測る。** 鍵の形が種類で違うため、テスト整備ラウンドを # 相手にすると重なりが常に 0 になり、収束の判定が働かない。 @@ -67,11 +67,6 @@ def cmd_advance(args: argparse.Namespace) -> None: sys.exit(1) -def _of_kind(rounds: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]: - """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" - return [r for r in rounds if entry_kind(r) == kind] - - def _advance_test_rounds( path: pathlib.Path, state: dict[str, Any], last: dict[str, Any] ) -> None: @@ -81,7 +76,7 @@ def _advance_test_rounds( 残っていても移る。**どちらで移ったかを記録する**(収束して終わったのか、 歯止めで止まったのかを報告で読み分けるため)。 """ - done = len(_of_kind(state["rounds"], TEST)) + done = len(rounds_of_kind(state["rounds"], TEST)) limit = safe_int(state.get("max_test_rounds"), DEFAULT_MAX_TEST_ROUNDS) if last.get("adopted") == 0: reason = "no_more_test_proposals" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index 8781201a..7e3da9f4 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -38,6 +38,7 @@ finish_outer_rounds, impl_for_seq, round_kind, + rounds_of_kind, ) from ..scope import require_scope_covers_tests, round_test_hint from ..vocabulary import ( @@ -756,11 +757,6 @@ def _run_round_test( return {"command": command, "status": "green", "checked_at": statefile.now()} -def rounds_of_kind(state: dict[str, Any], kind: str) -> list[dict[str, Any]]: - """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" - return [r for r in state.get("rounds") or [] if entry_kind(r) == kind] - - def _new_round_entry( state: dict[str, Any], round_no: int, kind: str ) -> dict[str, Any]: @@ -815,7 +811,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: rounds = state["rounds"] kind = round_kind(state) - if kind == STRUCTURE and len(rounds_of_kind(state, STRUCTURE)) >= state["max_outer_rounds"]: + if kind == STRUCTURE and len(rounds_of_kind(state.get("rounds") or [], STRUCTURE)) >= state["max_outer_rounds"]: finish_outer_rounds(path, state, "max_outer_rounds") sys.exit(1) @@ -830,7 +826,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: kind = entry_kind(existing) label, limit = _round_label_and_limit(state, kind) - seq = len(rounds_of_kind(state, kind)) + seq = len(rounds_of_kind(state.get("rounds") or [], kind)) info( f"=== {label} {seq} / {limit} " f"(実装 {existing['impl']})===" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py index cb80f8ce..5004afb6 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py @@ -36,6 +36,11 @@ def entry_kind(entry: dict[str, Any]) -> str: return TEST if entry.get("kind") == TEST else STRUCTURE +def rounds_of_kind(rounds: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]: + """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" + return [r for r in rounds if entry_kind(r) == kind] + + def item_kind(item: dict[str, Any]) -> str: """項目 1 件の種類。改善項目とテスト項目は同じ一覧に並ぶ。""" return TEST if item.get("kind") == TEST else STRUCTURE diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py index 155fb08e..59af5001 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py @@ -17,7 +17,7 @@ import os import pathlib import shlex -from typing import Any, Iterable, Optional +from typing import Any, Callable, Iterable, Optional from . import die from .gitfacts import is_test_path @@ -105,20 +105,10 @@ def baseline_search_roots(command: str, work: str) -> list[str]: 語として読めないコマンド(引用符が閉じていないなど)は限定なしとして扱う。 ここは範囲の宣言を読むための補助であり、コマンドの妥当性を判定する場所ではない。 """ - try: - words = shlex.split(str(command or "")) - except ValueError: - return [] - roots: list[str] = [] - for word in words[1:]: - if word.startswith("-") or os.path.isabs(word): - continue - if not (pathlib.Path(work) / word).is_dir(): - continue - normalized = os.path.normpath(word) - if normalized not in roots: - roots.append(normalized) - return roots + return _scope_roots( + command, work, + lambda word, previous, normalized: (pathlib.Path(work) / word).is_dir(), + ) def covered_by_roots(location: str, roots: list[str]) -> bool: @@ -144,6 +134,25 @@ def round_test_roots(command: str, work: str) -> list[str]: ラッパー)も数えない。**ラッパーの中身は解析しない。** 範囲の外を走らせても、 最終ゲートの全体テストが見る。 """ + def accept(word: str, previous: str, normalized: str) -> bool: + if previous.startswith("--") and "=" not in previous: + return False + if normalized == ".": + return False + target = pathlib.Path(work) / word + return target.is_dir() or (target.is_file() and is_test_path(normalized)) + + return _scope_roots(command, work, accept) + + +def _scope_roots( + command: str, work: str, accept: Callable[[str, str, str], bool] +) -> list[str]: + """コマンドの語のうち `accept(語, 直前の語, 正規化した語)` が真のものを返す。 + + **先頭の語**(プログラム名)と `-` で始まる語、絶対パスは見ない。正規化した + 語を重複なく、現れた順に集める。語として読めないコマンドは空を返す。 + """ try: words = shlex.split(str(command or "")) except ValueError: @@ -151,17 +160,11 @@ def round_test_roots(command: str, work: str) -> list[str]: roots: list[str] = [] previous = words[0] if words else "" for word in words[1:]: - is_option_value = previous.startswith("--") and "=" not in previous - previous = word - if is_option_value or word.startswith("-") or os.path.isabs(word): + before, previous = previous, word + if word.startswith("-") or os.path.isabs(word): continue normalized = os.path.normpath(word) - if normalized == ".": - continue - target = pathlib.Path(work) / word - if not (target.is_dir() or (target.is_file() and is_test_path(normalized))): - continue - if normalized not in roots: + if accept(word, before, normalized) and normalized not in roots: roots.append(normalized) return roots From cec4001ce12e8d6d2e2727c6f544302ddc87f2bb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 15:52:25 +0000 Subject: [PATCH 15/19] =?UTF-8?q?Fix:=20#880=20=E3=82=AF=E3=83=AD=E3=82=B9?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=20round=201:=20round=5Ftes?= =?UTF-8?q?t=5Froots=20=E3=81=8C=E5=80=A4=E3=82=92=E5=8F=96=E3=82=89?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=82=AA=E3=83=97=E3=82=B7=E3=83=A7=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E7=9B=B4=E5=BE=8C=E3=81=AE=E5=AF=BE=E8=B1=A1=E3=82=92?= =?UTF-8?q?=E6=B6=88=E3=81=95=E3=81=AA=E3=81=84=E3=82=88=E3=81=86=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scope.py: 値を取ると分かっているオプション(VALUE_OPTIONS)の直後だけを値として除く。 知らないオプションの直後の語は起点に数え、関門を止める側に倒す - test_scope_gate.py: `pytest --verbose tests/services` などの回帰テストを追加 - gate.py: 上限到達の分岐の後に return を足し、終了経路の形を揃える - SKILL.md: 前提節の `$SCRIPTS` が「実行」節で解決する scripts ディレクトリであることを添える Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- plugins/ndf/skills/cross-refactoring/SKILL.md | 3 +- .../scripts/refactor_lib/commands/gate.py | 1 + .../scripts/refactor_lib/scope.py | 32 ++++++++++++++++--- .../tests/test_scope_gate.py | 25 +++++++++++++++ 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index 826439c8..1d9e20a9 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -179,7 +179,8 @@ allowed-tools: - 本番コードの差分がある。起動の前に `assess` で飛ばしてよいかを見る。**終了コード 3 なら 起動しない**(本番コードの差分が無いか、変更が `--max-lines`(既定 10)行以下)。2 は 判定できなかったことを示し、飛ばしてよいとは読まない。飛ばしたときの記録の残し方は - `development-workflow` の `references/workflow-modes.md`「構造改善の退避先」にある + `development-workflow` の `references/workflow-modes.md`「構造改善の退避先」にある。 + `$SCRIPTS` は「実行」節の決め方で解決したこの Skill の `scripts` ディレクトリである ```bash python3 "$SCRIPTS/refactor.py" assess --base origin/develop; echo "exit=$?" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index 46df2082..af410917 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -87,6 +87,7 @@ def cmd_final_gate(args: argparse.Namespace) -> None: limit = safe_int(state.get("max_fix_rounds"), 3) if safe_int(gate.get("fix_rounds")) >= limit: _gate_limit_reached(path, state, gate, detail, limit) + return _gate_failing(path, state, gate, detail, limit) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py index 59af5001..7ac08f3f 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py @@ -34,6 +34,25 @@ ) +# `--round-test` のうち、次の語を値として取ると分かっているオプション(#880)。 +# `--opt=value` の形は 1 語に値を含むため並べない。**知らないオプションの直後の語は +# 起点に数える**(`round_test_roots`)。ここに無い値付きオプションは関門を止める側に +# 倒れるだけで、範囲の外を素通りさせない。 +VALUE_OPTIONS: frozenset[str] = frozenset({ + # uv run + "--project", "--directory", "--with", "--with-editable", "--with-requirements", + "--python", "-p", "--package", "--extra", "--group", "--only-group", + "--env-file", "--index", "--default-index", "--index-url", "--extra-index-url", + "--find-links", "-f", "--cache-dir", "--config-file", + # pytest + "--rootdir", "-c", "--confcutdir", "--basetemp", "-k", "-m", "-n", + "--numprocesses", "--dist", "-o", "--override-ini", "--tb", "--ignore", + "--ignore-glob", "--deselect", "--junit-xml", "--junitxml", "--log-file", + "--cov", "--cov-report", "--cov-config", "--maxfail", "--durations", + "-W", "--pythonwarnings", "--import-mode", "--capture", "-r", +}) + + def _matches_by_name(path: str) -> bool: """名前だけで置き場所と読めるか。**実在は見ない。** @@ -128,14 +147,19 @@ def round_test_roots(command: str, work: str) -> list[str]: 数える。** 範囲のテストは 1 ファイルを名指しすることがあり、それを限定なしと読むと 範囲の置き場所を走らせないコマンドが関門を通る。 - 数えない語は 3 つある。先頭の語(プログラム名)と `-` で始まる語、`=` を含まない - 長いオプションの直後の語(`--project .` の `.`)、作業ディレクトリの根(`.`)で - ある。テストの置き場所でないファイル(`bash scripts/run-scope-tests.sh` の + 数えない語は 3 つある。先頭の語(プログラム名)と `-` で始まる語、値を取ると + 分かっているオプションの直後の語(`--project .` の `.`)、作業ディレクトリの根 + (`.`)である。テストの置き場所でないファイル(`bash scripts/run-scope-tests.sh` の ラッパー)も数えない。**ラッパーの中身は解析しない。** 範囲の外を走らせても、 最終ゲートの全体テストが見る。 + + **値を取るかが分からないオプションの直後の語は起点に数える。** 値とみなして + 消すと、`pytest --verbose tests/unit` の唯一の対象が消えて起点が空になり、全体を + 覆うとみなして範囲の外だけを走らせるコマンドが関門を通る。数えすぎたときは + 関門が止まる側に倒れ、利用者が 1 度直せば済む。 """ def accept(word: str, previous: str, normalized: str) -> bool: - if previous.startswith("--") and "=" not in previous: + if previous in VALUE_OPTIONS: return False if normalized == ".": return False diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py b/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py index 573f5d94..6d63fc3c 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py @@ -215,6 +215,31 @@ def test_a_wrapper_script_is_not_a_round_test_root(scope, tmp_path, command): assert scope.round_test_roots(command, str(tmp_path)) == [] +@pytest.mark.parametrize("command, expected", [ + ("pytest --verbose tests/services", ["tests/services"]), + ("pytest -q --tb short tests/services/test_one.py", ["tests/services/test_one.py"]), + ("uv run --project scripts --with pytest pytest --lf tests/services", ["tests/services"]), + ("pytest --rootdir scripts -p no:cacheprovider tests/services", ["tests/services"]), +]) +def test_a_flag_without_a_value_keeps_the_next_round_test_root(scope, tmp_path, command, expected): + """値を取らないオプションの直後の対象を、オプションの値として消さない。 + + 消すと起点が空になり、全体を覆うとみなして範囲の外だけを走らせるコマンドが関門を通る。 + 値を取ると分かっているオプション(`--project` / `--rootdir` / `-p`)の直後だけを除く。 + """ + _services(tmp_path) + assert scope.round_test_roots(command, str(tmp_path)) == expected + + +def test_a_flag_before_an_outside_target_stops(refactor_lib, scope, tmp_path): + """真偽のオプションの直後の範囲外の対象だけを走らせるコマンドは止める。""" + _services(tmp_path) + problem = scope.scope_problem( + ["src", "tests/services"], "pytest --verbose scripts", str(tmp_path), + round_test=True) + assert problem is not None and "--round-test" in problem + + def test_a_directory_is_a_round_test_root(scope, tmp_path): _services(tmp_path) assert scope.round_test_roots( From 6ccb80351b7ac4c0d815f03e82efaf9245a4003c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 15:53:21 +0000 Subject: [PATCH 16/19] =?UTF-8?q?Fix:=20#880=20CLAUDE.md=20=E3=81=AE?= =?UTF-8?q?=E7=89=88=E3=81=8C=E6=B1=BA=E3=81=BE=E3=82=8B=E5=89=8D=E3=81=AE?= =?UTF-8?q?=E6=AE=B5=E8=90=BD=E3=81=AE=E5=9F=BA=E5=BA=95=E3=82=92=E3=80=81?= =?UTF-8?q?=E5=87=BA=E3=81=9F=2010.17.4=20=E3=81=B8=E5=90=88=E3=82=8F?= =?UTF-8?q?=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index fbf3effd..82d0e8ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ python3 plugins/ndf/scripts/instructions-check.py --root . ``` - `--scope` は必須。提案が発散して PR が肥大するのを防ぐ。**検証にも効く**ので、現状固定テストの置き場所も含める -- v10.17.3 の次の版で、群ごとの検証は `--round-test`(範囲のテスト)で走らせ、`--baseline-test`(全体のテスト)は着手前と最終ゲートの 2 回だけ走らせる。`--round-test` を省くと全体のテストが群ごとに走る +- v10.17.4 の次の版で、群ごとの検証は `--round-test`(範囲のテスト)で走らせ、`--baseline-test`(全体のテスト)は着手前と最終ゲートの 2 回だけ走らせる。`--round-test` を省くと全体のテストが群ごとに走る - ホストと同じランタイムが適用担当になる場合も、サブエージェントではなく **CLI プロセス**として起動する - モデルを比べるなら `--model <ランタイム>=` を参加者の全員に指定する。実際に動いたモデルを取得できるのは claude だけで、残りは指定値で代用する。指定が無いラウンドは集計から分離される - 適用担当は参加者の数のラウンドで 1 周する。輪番は適用ラウンドごとに進むため、`--max-outer-rounds`(既定 3)が切る提案の回数とは対応しない From 13c65edeb739b54f759bce0b382478c543f4170d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 16:04:03 +0000 Subject: [PATCH 17/19] =?UTF-8?q?Fix:=20#880=20=E3=82=AF=E3=83=AD=E3=82=B9?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=20round=202:=20=E9=81=A9?= =?UTF-8?q?=E7=94=A8=E3=83=BB=E4=BF=AE=E6=AD=A3=E3=81=AE=E6=8B=85=E5=BD=93?= =?UTF-8?q?=E3=81=B8=E7=BE=A4=E3=81=AE=E6=A4=9C=E8=A8=BC=E3=81=A8=E5=90=8C?= =?UTF-8?q?=E3=81=98=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E6=B8=A1=E3=81=97?= =?UTF-8?q?=E3=80=81assess=20=E3=81=AE=E4=BE=8B=E3=82=92=E6=A4=9C=E6=9F=BB?= =?UTF-8?q?=E3=81=AE=E5=AF=BE=E8=B1=A1=E3=81=B8=E5=87=BA=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - launch-cli.sh: apply / fix の雛形へ `.round_test.command // .baseline_test.command` を `$RF_ROUND_TEST` として渡す。進行側の `round_test_command` と同じ解決にし、担当が 走らせるテストと合否を決めるテストを揃える。final-fix は全体のテスト(baseline)のまま - 生成したプロンプトに実際のコマンドが入ることを確かめるテストを足す - SKILL.md: 前提節の `assess` の bash ブロックを箇条書きの外へ出し、`$SCRIPTS` の出所を ブロック内に置く(check-skill-shell-vars.py の検査対象にする) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- plugins/ndf/skills/cross-refactoring/SKILL.md | 9 +-- .../skills/cross-refactoring/prompts/apply.md | 4 +- .../skills/cross-refactoring/prompts/fix.md | 6 +- .../cross-refactoring/scripts/launch-cli.sh | 6 +- .../tests/test_launch_round_test.py | 69 +++++++++++++++++++ 5 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_launch_round_test.py diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index 1d9e20a9..cac0c587 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -180,11 +180,12 @@ allowed-tools: 起動しない**(本番コードの差分が無いか、変更が `--max-lines`(既定 10)行以下)。2 は 判定できなかったことを示し、飛ばしてよいとは読まない。飛ばしたときの記録の残し方は `development-workflow` の `references/workflow-modes.md`「構造改善の退避先」にある。 - `$SCRIPTS` は「実行」節の決め方で解決したこの Skill の `scripts` ディレクトリである - ```bash - python3 "$SCRIPTS/refactor.py" assess --base origin/develop; echo "exit=$?" - ``` +```bash +# 「実行」節の決め方で解決したこの Skill の scripts ディレクトリ +SCRIPTS="<この Skill のディレクトリ>/scripts" +python3 "$SCRIPTS/refactor.py" assess --base origin/develop; echo "exit=$?" +``` ## 全体フロー diff --git a/plugins/ndf/skills/cross-refactoring/prompts/apply.md b/plugins/ndf/skills/cross-refactoring/prompts/apply.md index c3091132..dcba071c 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/apply.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/apply.md @@ -13,7 +13,7 @@ $RF_ROUND_NOTE - リポジトリ: $RF_REPO / Pull Request #$RF_PR - 作業ディレクトリ: `$RF_WORKDIR`(**ここでだけ作業する**) - ブランチ: `$RF_HEAD_BRANCH`(base は `$RF_BASE_BRANCH`) -- テストコマンド: `$RF_BASELINE_TEST` +- テストコマンド: `$RF_ROUND_TEST` ## 手順書 @@ -29,7 +29,7 @@ $RF_ITEMS 1. 配列の順に、各項目の `plan` の手順を適用する。`test_gap` が真の項目では、 その項目の現状固定テストも同じ作業の中で足す -2. **全項目を終えてから** `$RF_BASELINE_TEST` を **1 回**実行する。落ちたら原因の手を戻す +2. **全項目を終えてから** `$RF_ROUND_TEST` を **1 回**実行する。落ちたら原因の手を戻す 3. 通ったら、**この適用ラウンドの変更をまとめて 1 コミットにする** **テストもコミットも適用ラウンドの単位で 1 回です。** 手ごとや項目ごとに回すと、 diff --git a/plugins/ndf/skills/cross-refactoring/prompts/fix.md b/plugins/ndf/skills/cross-refactoring/prompts/fix.md index d0fdf562..167ee9b7 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/fix.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/fix.md @@ -9,7 +9,7 @@ - リポジトリ: $RF_REPO / Pull Request #$RF_PR - 作業ディレクトリ: `$RF_WORKDIR`(**ここでだけ作業する**) - ブランチ: `$RF_HEAD_BRANCH` -- テストコマンド: `$RF_BASELINE_TEST` +- テストコマンド: `$RF_ROUND_TEST` ## 手順書 @@ -25,11 +25,11 @@ $RF_ITEMS ## やること -1. `$RF_BASELINE_TEST` を実行し、**どのテストがなぜ落ちているか**を読む +1. `$RF_ROUND_TEST` を実行し、**どのテストがなぜ落ちているか**を読む 2. 落ちている原因が、上の項目の適用によるものかを確かめる 3. **振る舞いを元へ戻す方向で**直す。テストの側を書き換えて通すのではありません (**テストを緩めた時点で、振る舞い不変を示す手段が消えます**) -4. 直してから `$RF_BASELINE_TEST` をもう 1 回実行し、**項目ごとに 1 コミット**へまとめる +4. 直してから `$RF_ROUND_TEST` をもう 1 回実行し、**項目ごとに 1 コミット**へまとめる **振る舞いを変えないと直せない**と判断したときは、直さずに終えてください。 修正ラウンドの上限に達すると、**その適用ラウンドごと取り消されます**。無理に diff --git a/plugins/ndf/skills/cross-refactoring/scripts/launch-cli.sh b/plugins/ndf/skills/cross-refactoring/scripts/launch-cli.sh index cd357584..61efef94 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/launch-cli.sh +++ b/plugins/ndf/skills/cross-refactoring/scripts/launch-cli.sh @@ -41,6 +41,10 @@ load_common_state() { SCOPE=$(jq -r '.target_scope | join(" ")' "$STATE") MODEL=$(jq -r --arg rt "$RUNTIME" '.models[$rt] // ""' "$STATE") BASELINE_TEST=$(jq -r '.baseline_test.command // ""' "$STATE") + # 適用と修正の担当には、進行側が群と修正コミットを検証するコマンドを渡す(#880)。 + # `round_test_command` と同じく、`round_test` が無ければ `baseline_test` を採る。 + # 最終ゲートの修正(final-fix)は全体のテストで判定するため `BASELINE_TEST` を使う。 + ROUND_TEST=$(jq -r '.round_test.command // .baseline_test.command // ""' "$STATE") MAX_ITEMS=$(jq -r '.max_items_per_round' "$STATE") # ラウンド番号は表示と項目の絞り込みに使う。未指定なら開いている最新ラウンドを採る。 @@ -228,7 +232,7 @@ export_prompt_env() { export RF_REPO=$REPO RF_PR=$PR RF_ROUND=${ROUND:-} RF_RUNTIME=$RUNTIME export RF_MODEL=${MODEL:-default} RF_WORKDIR=$WORKDIR RF_STEM=$STEM export RF_SCOPE=$SCOPE RF_HEAD_BRANCH=$HEAD_BRANCH RF_BASE_BRANCH=$BASE_BRANCH -export RF_BASELINE_TEST=$BASELINE_TEST RF_MAX_ITEMS=$MAX_ITEMS +export RF_BASELINE_TEST=$BASELINE_TEST RF_ROUND_TEST=$ROUND_TEST RF_MAX_ITEMS=$MAX_ITEMS export RF_SKILL_BLOCK=$SKILL_BLOCK RF_EXCLUDED=$EXCLUDED RF_SKILL_BASE=$SKILL_BASE export RF_ITEMS=$ITEMS_JSON RF_TMP_DIR=$TMP_DIR export RF_APPLY_ROUND=$APPLY_ROUND diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_launch_round_test.py b/plugins/ndf/skills/cross-refactoring/tests/test_launch_round_test.py new file mode 100644 index 00000000..0f72535e --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_launch_round_test.py @@ -0,0 +1,69 @@ +"""担当へ渡すテストコマンドが、進行側の検証と同じであること(#880)。 + +進行側は群と修正コミットを `round_test_command(state)`(`round_test`、無ければ +`baseline_test`)で検証する。適用と修正の担当が別のコマンドを走らせると、担当が +通したつもりの変更を進行側が落とす(またはその逆)。最終ゲートの修正は全体の +テストで判定するため、`baseline_test` のままである。 +""" +from __future__ import annotations + +import os +import pathlib +import subprocess + +import pytest + +from crossref_helpers import make_state + +LAUNCH = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "launch-cli.sh" +BASELINE = "pytest -q whole-suite" +ROUND_TEST = "pytest -q tests/unit/test_scope_only.py" + + +def _prompt(tmp_path, phase, round_test): + over = {"baseline_test": {"command": BASELINE, "status": "green"}, + "rounds": [{"round": 1, "apply_round": 1}], + "items": [{"item_id": "R1-001", "round": 1, "apply_round": 1}]} + if round_test is not None: + over["round_test"] = {"command": round_test} + state_path = make_state(tmp_path, **over) + (tmp_path / "work").mkdir(exist_ok=True) + bin_dir = tmp_path / "bin" + bin_dir.mkdir(exist_ok=True) + stub = bin_dir / "codex" + stub.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + stub.chmod(0o755) + args = [str(LAUNCH), "codex", phase, "130"] + if phase != "final-fix": + args.append("1") + subprocess.run( + args, + env={**os.environ, + "CROSS_REFACTORING_TMP_DIR": str(state_path.parent), + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}"}, + check=True, capture_output=True, text=True, + ) + name = "codex-final-fix" if phase == "final-fix" else f"codex-{phase}-r1" + return (state_path.parent / f"{name}-prompt.md").read_text(encoding="utf-8") + + +@pytest.mark.parametrize("phase", ["apply", "fix"]) +def test_apply_and_fix_run_the_round_test(tmp_path, phase): + text = _prompt(tmp_path, phase, ROUND_TEST) + assert ROUND_TEST in text + assert BASELINE not in text + assert "$RF_" not in text + + +@pytest.mark.parametrize("phase", ["apply", "fix"]) +def test_apply_and_fix_fall_back_to_the_baseline(tmp_path, phase): + """`round_test` を持たない状態ファイルは、進行側の検証と同じく baseline になる。""" + text = _prompt(tmp_path, phase, None) + assert BASELINE in text + assert "$RF_" not in text + + +def test_final_fix_runs_the_whole_suite(tmp_path): + text = _prompt(tmp_path, "final-fix", ROUND_TEST) + assert BASELINE in text + assert ROUND_TEST not in text From f9d362b7cb734e9d002cacf489d05f1adbf45c08 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 16:09:31 +0000 Subject: [PATCH 18/19] =?UTF-8?q?Fix:=20#880=20=E3=82=AF=E3=83=AD=E3=82=B9?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=20round=203:=20--round-tes?= =?UTF-8?q?t=20=E3=81=AE=E3=83=8E=E3=83=BC=E3=83=89=20ID=20=E3=82=92?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E9=83=A8=E5=88=86?= =?UTF-8?q?=E3=81=A7=E8=B5=B7=E7=82=B9=E3=81=AB=E6=95=B0=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- .../scripts/refactor_lib/scope.py | 5 ++++- .../tests/test_scope_gate.py | 20 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py index 7ac08f3f..b5edf2a1 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py @@ -174,7 +174,8 @@ def _scope_roots( ) -> list[str]: """コマンドの語のうち `accept(語, 直前の語, 正規化した語)` が真のものを返す。 - **先頭の語**(プログラム名)と `-` で始まる語、絶対パスは見ない。正規化した + **先頭の語**(プログラム名)と `-` で始まる語、絶対パスは見ない。ノード ID は + `::` より前のファイルの部分で読む。正規化した 語を重複なく、現れた順に集める。語として読めないコマンドは空を返す。 """ try: @@ -187,6 +188,8 @@ def _scope_roots( before, previous = previous, word if word.startswith("-") or os.path.isabs(word): continue + # pytest のノード ID(`<ファイル>::<名前>`)はファイルの部分で読む。 + word = word.split("::", 1)[0] normalized = os.path.normpath(word) if accept(word, before, normalized) and normalized not in roots: roots.append(normalized) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py b/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py index 6d63fc3c..d0d0a8be 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_scope_gate.py @@ -198,6 +198,26 @@ def test_a_test_file_is_a_round_test_root(scope, tmp_path): "tests/services/test_one.py"] +@pytest.mark.parametrize("command", [ + "pytest tests/services/test_one.py::test_a", + "pytest tests/services/test_one.py::TestA::test_b -q", +]) +def test_a_node_id_counts_its_file_as_a_round_test_root(scope, tmp_path, command): + """ノード ID はファイルの部分を起点に数える。数えないと起点が空になり、全体を覆うとみなす。""" + _services(tmp_path) + assert scope.round_test_roots(command, str(tmp_path)) == [ + "tests/services/test_one.py"] + + +def test_a_node_id_narrower_than_the_scope_stops(refactor_lib, scope, tmp_path): + """範囲のテストの置き場所の一部しか走らせないノード ID は関門で止める。""" + _services(tmp_path) + problem = scope.scope_problem( + ["src", "tests/services"], "pytest tests/services/test_one.py::test_a", + str(tmp_path), round_test=True) + assert problem is not None and "--round-test" in problem + + def test_an_option_value_and_the_work_root_are_not_round_test_roots(scope, tmp_path): """`--project .` の `.` はオプションの値で、作業ディレクトリの根でもある。""" _services(tmp_path) From d970937c04dc09549f96df79fced157509986bd0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 16:15:33 +0000 Subject: [PATCH 19/19] =?UTF-8?q?Docs:=20#880=20=E6=9C=80=E7=B5=82?= =?UTF-8?q?=E3=82=B9=E3=82=A4=E3=83=BC=E3=83=97:=20=E8=A9=B3=E7=B4=B0?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E3=81=AE=E9=96=A2=E9=96=80=E3=83=BB=E7=9D=80?= =?UTF-8?q?=E6=89=8B=E5=89=8D=E3=83=BB=E7=BE=A4=E3=81=AE=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E3=82=92=20--round-test=20=E3=81=AE=E5=A5=91=E7=B4=84=E3=81=B8?= =?UTF-8?q?=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DhcogXCb1x3eStK4VoDDCy --- .../skills/cross-refactoring/docs/01-state-and-propose.md | 6 +++--- .../skills/cross-refactoring/docs/02-apply-and-review.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md b/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md index 68434085..6b61f636 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md +++ b/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md @@ -58,10 +58,10 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 実行は初期化時ではなく、**push の直前**に進行側が行う。同期を実装担当の責務に すると範囲外の変更になり、範囲の検査で全件失敗する(実測 0/5) 9. **`--scope` の関門** — テストの置き場所が範囲に含まれること**と**、その置き場所が - `--baseline-test` の実行集合に入ることを確かめる。どちらかを満たさなければ + `--round-test`(省けば `--baseline-test`)の実行集合に入ることを確かめる。どちらかを満たさなければ **中断する**(終了コード 4)。**案内だけでは同じ失敗を繰り返す**(実測では 4 ラウンド続けて同じ理由で項目が落ちた)。止めれば利用者は 1 度だけ範囲を直せばよい -10. **着手前のテスト** — `--baseline-test` を実行する。**失敗していたら開始しない**。 +10. **着手前のテスト** — `--baseline-test` を実行し、`--round-test` を渡したときはそれも 1 回実行する。**どちらかが失敗していたら開始しない**。 壊れた状態から始めると、壊したのか元から壊れていたのか区別できない。 この引数は**必須**である。振る舞いが変わっていないことを示す手段が無い書き換えは、 `refactoring` Skill の定義からして構造改善ではない @@ -71,7 +71,7 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" | 見るもの | 見ない場合に起きること | | --- | --- | | `--scope` にテストの置き場所が含まれているか | テスト整備ラウンドが足すテストが範囲外になり、その項目は必ず失敗する | -| その置き場所が `--baseline-test` の実行集合に入るか | 足したテストが一度も実行されず、検証(Step 5)の判定に効かない | +| その置き場所が `--round-test`(省けば `--baseline-test`)の実行集合に入るか | 足したテストが一度も実行されず、検証(Step 5)の判定に効かない | **2 つを 1 つの関門で見る。** 直す先はどちらも利用者が与える引数であり、別々に 止めると 2 度直すことになる。 diff --git a/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md b/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md index 81185715..77f07b88 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md +++ b/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md @@ -284,7 +284,7 @@ fi 整備ラウンドで「テストを足せ」と「範囲外を触るな」が両立しなくなり、その項目は 必ず失敗する。**`init` がこれを関門として見て、含まれていなければ止める** (#436 決定 5。案内だけでは同じ失敗を繰り返し、実測では 4 ラウンド続けて同じ理由で -項目が落ちた)。**あわせて、その置き場所が `--baseline-test` の実行集合に入ることも +項目が落ちた)。**あわせて、その置き場所が `--round-test`(省けば `--baseline-test`)の実行集合に入ることも 同じ関門で見る**(入らないと、足したテストが検証で一度も実行されない)。 ```bash @@ -454,11 +454,11 @@ Impl-Model: gpt-5.5 ``` **判定はテストの結果で決まる**(#436 決定 3)。**2 CLI のレビューは起動しない。** -`--baseline-test` が指すコマンドを作業ディレクトリの HEAD で実行し、その合否で決める。 +`--round-test`(範囲のテスト。省けば `--baseline-test`)が指すコマンドを作業ディレクトリの HEAD で実行し、その合否で決める。 | 決めること | 結論 | | --- | --- | -| 何を実行するか | `--baseline-test` が指すコマンド。着手前と同じものを使う | +| 何を実行するか | `--round-test` が指すコマンド。省いたときは `--baseline-test`。全体のテストは着手前と最終ゲートの 2 回だけ走る | | 判定の単位 | **適用ラウンド(群)。** 失敗を項目までは特定しない | | 失敗したら | 修正ラウンドを回す。上限に達したら**その群だけ**取り消す | | 継続的統合で代替できるか | **代替しない**(後述) |