From e96fa5cab86f043bd1b1b6536d56110cd668287e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 01:24:04 +0000 Subject: [PATCH 01/35] =?UTF-8?q?Feat:=20pace:=20normal=20=E3=81=AE=20new?= =?UTF-8?q?=20sprint=20=E3=81=8C=E6=89=BF=E8=AA=8D=E3=82=B2=E3=83=BC?= =?UTF-8?q?=E3=83=88=202=20=E3=81=A8=E6=9C=AC=E7=95=AA=E3=81=AE=E3=82=B9?= =?UTF-8?q?=E3=83=86=E3=83=BC=E3=82=B8=E3=82=92=E6=9B=B8=E3=81=8D=E5=87=BA?= =?UTF-8?q?=E3=81=99=EF=BC=88#1914=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 雛形の経路で本番のブランチがあれば、配布の後に「関門 2」(gate)と「本番」(release-prod、 then_of なし)を置く。本番のプランは plan_sprint_release にチャネルと版を渡して組み、 出す PR は {queue_pr:check}、MVV 判定は置かない。承認ゲート 2 の文は関門 2 の gate へ移し、 本番のブランチが無いときは今のまま配布で終えて next に理由を書く。 sprint.py の 500 行の上限のため、利用者の承認の文の関数を sprint_waves.py へ移した。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/sprint.py | 56 +++--- .../ndf/scripts/supervise_lib/sprint_waves.py | 54 +++++- .../plans/sprint/9-release-prod.json | 160 ++++++++++++++++++ .../plans/sprint/sprint.json | 13 ++ .../scripts/tests/test_project_mvv_normal.py | 4 +- plugins/ndf/scripts/tests/test_supervise.py | 56 +++++- .../ndf/scripts/tests/test_supervise_new.py | 12 ++ 7 files changed, 312 insertions(+), 43 deletions(-) create mode 100644 plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/9-release-prod.json diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index a5d74680b..e770ab5fe 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -21,6 +21,9 @@ plan_sprint_design, plan_sprint_impl, plan_sprint_release, + normal_gate_1, + normal_gate_2, + normal_gate_2_stage, plan_advise_design, plan_design_results, plan_mvv_design, @@ -325,33 +328,16 @@ def sprint_plans(a) -> list[dict]: if not has_release_template(a): return waves + route_waves(a, repo, "検査", note=normal_gate_2(a) if advise else None) release = {"name": "配布", "plans": {"release": plan_sprint_release(a, repo, advise)}, "then_of": "検査"} - if advise: - release["note"] = normal_gate_2(a) - return waves + [release] - - -def user_gate_cmd(a, gate: str, pr: bool) -> str: - """利用者の答えを状態へ書くコマンド(覆しの記録。#1400 の AC6)。承認ゲート 1 は答えた設計 PR を --pr で渡す。""" - state = shlex.quote(sprint_state_path(a)) - return ( - f"python3 {shlex.quote(str(HERE / 'sprint-state.py'))} gate {state} {shlex.quote(gate)} --what <要約> --by user" - + (" --pr <設計 PR>" if pr else "") - + " --outcome approved|rejected" - ) - - -def normal_gate_1(a) -> str: - return ( - "設計 Pull Request をまとめて承認してマージする。承認資料には各設計 PR の助言の MVV 判定(PR のコメント。無ければ「MVV なし」)を" - f"載せる。利用者が答えたら、設計 PR ごとに `{user_gate_cmd(a, '関門 1', True)}` を打つ" - ) - - -def normal_gate_2(a) -> str: - return ( - "承認ゲート 2: 承認資料の末尾に助言の MVV 判定が載る(無ければ「MVV なし」)。利用者が本番への配布に答えたら、" - f"`{user_gate_cmd(a, '関門 2', False)}` を打つ" - ) + if not getattr(a, "production_branch", None): # 本番を組めないなら今のまま配布で終える(#1914 の決定 3) + if advise: + release["note"] = normal_gate_2(a) + return waves + [release] + prod = prod_version(a.version) + return waves + [ + release, + {"name": "関門 2", "gate": normal_gate_2_stage(a, prod, advise)}, + {"name": "本番", "plans": {"release-prod": plan_sprint_release(a, repo, channel="prod", version=prod)}}, + ] def sprint_command(a, drop: tuple[str, ...] = (), design: list[int] | None = None) -> str: @@ -417,7 +403,7 @@ def cmd_new_sprint(a, waves: list[dict] | None = None) -> dict: f"スプリント {a.name} の計画を {plans} 本・{len(index)} ステージで書いた: {manifest}", index, {"waves": len(index), "plans": plans, "manifest": str(manifest)}, - next=next_text(pace, index, advise=pace == "normal" and advises(a, closing)), + next=next_text(pace, index, advise=pace == "normal" and advises(a, closing), no_prod=no_prod_stage(a, pace, closing)), ) @@ -481,13 +467,19 @@ def manifest_head(a, pace: str, closing: bool = False) -> dict: return head -def next_text(pace: str, index: list[dict], advise: bool = False) -> str: +def no_prod_stage(a, pace: str, closing: bool) -> bool: + """normal の雛形の経路で、本番のブランチが無いため本番のステージを置かなかったか(#1914 の決定 3)。""" + return pace == "normal" and not closing and not getattr(a, "production_branch", None) and has_release_template(a) + + +def next_text(pace: str, index: list[dict], advise: bool = False, no_prod: bool = False) -> str: """次に打つ手の文面。auto だけ別の文で、manual のステージがあれば追記する。`advise`(normal と --state)なら - 承認資料に助言の MVV 判定を載せ、答えを状態へ書くことを足す。""" + 承認資料に助言の MVV 判定を載せ、答えを状態へ書くことを足す。`no_prod` なら本番を置かなかった理由を足す。""" nxt = "ステージの番号の順に command を打つ。関門のステージでは承認を取ってから次へ進む" if advise: nxt += ( - "。承認資料には助言の MVV 判定を載せ、利用者が答えたら関門 1 のステージの gate・配布のステージの note にある " + "。承認資料には助言の MVV 判定を載せ、利用者が答えたら関門 1 のステージの gate・承認ゲート 2 の文(関門 2 のステージの gate か、" + "配布の後のステージの note)にある " "sprint-state.py gate(--what と --by user。関門 1 は --pr も)を打つ" ) if pace == "auto": @@ -495,6 +487,8 @@ def next_text(pace: str, index: list[dict], advise: bool = False) -> str: "最初のステージの command を打つ(後ろのステージは --then で続く)。queue が gate を返したら、承認資料に判定の理由と" "根拠の項目を添えて承認を取り、関門のステージの説明に沿って続きのステージの resume を打つ" ) + if no_prod: + nxt += "。本番のブランチ(--production-branch か .ndf/worktree.json の production_branch)が無いため、承認ゲート 2 と本番は置いていない" for manual in (e for e in index if "manual" in e): nxt += f"。{manual['name']}は {manual['manual']} で行う({manual['note']})" return nxt diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index 5acc001c6..46e2b0a5c 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -12,7 +12,7 @@ import legacy_names from supervise_lib.decl import decl_fields -from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP +from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, HERE, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP from supervise_lib.procedures import DESIGN_RESULTS, pr_step, requirements_path, sprint_materials, sprint_out, with_record from supervise_lib.release_templates import MVV_NOTE, advise_steps, plan_release from supervise_lib.templates import plan_check, plan_check_since, plan_impl @@ -313,26 +313,62 @@ def plan_sprint_check(a, repo: str) -> dict: return plan -def plan_sprint_release(a, repo: str, advise: bool = False) -> dict: - """開発版の配布。出す PR は検査のプランのスプリントの PR(`{queue_pr:check}`)で、検査の queue の --then でも - 単独で流し直しても埋まる(#1915)。`advise` なら承認資料の後に助言の MVV 判定を置く(pace: normal と --state。#1400)。""" +def plan_sprint_release(a, repo: str, advise: bool = False, channel: str = "dev", version: str | None = None) -> dict: + """pace: normal の配布。出す PR は検査のプランのスプリントの PR(`{queue_pr:check}`)で、検査の queue の --then でも + 単独で流し直しても埋まる(#1915)。本番(channel が prod)も同じ組み立てで、承認ゲート 2 の後に単独で流す(#1914)。 + `advise` なら開発版の承認資料の後に助言の MVV 判定を置く(pace: normal と --state。#1400)。本番には置かない。""" + version = version or a.version ns = argparse.Namespace( **decl_fields(a), - version=a.version, + version=version, prs=["{queue_pr:check}"], prs_from_queue=False, - channel="dev", + channel=channel, repo=repo, prev_tag=None, - worktree=f"{repo}/.worktrees/release/v{a.version}", - branch=f"release/v{a.version}", + worktree=f"{repo}/.worktrees/release/v{version}", + branch=f"release/v{version}", issue=a.issue, mode=a.mode, - advise=a.state if advise else None, + advise=a.state if advise and channel == "dev" else None, ) return plan_release(ns) +def user_gate_cmd(a, gate: str, pr: bool) -> str: + """利用者の答えを状態へ書くコマンド(覆しの記録。#1400 の AC6)。承認ゲート 1 は答えた設計 PR を --pr で渡す。""" + state = shlex.quote(str(Path(a.state).resolve())) + return ( + f"python3 {shlex.quote(str(HERE / 'sprint-state.py'))} gate {state} {shlex.quote(gate)} --what <要約> --by user" + + (" --pr <設計 PR>" if pr else "") + + " --outcome approved|rejected" + ) + + +def normal_gate_1(a) -> str: + return ( + "設計 Pull Request をまとめて承認してマージする。承認資料には各設計 PR の助言の MVV 判定(PR のコメント。無ければ「MVV なし」)を" + f"載せる。利用者が答えたら、設計 PR ごとに `{user_gate_cmd(a, '関門 1', True)}` を打つ" + ) + + +def normal_gate_2(a) -> str: + return ( + "承認ゲート 2: 承認資料の末尾に助言の MVV 判定が載る(無ければ「MVV なし」)。利用者が本番への配布に答えたら、" + f"`{user_gate_cmd(a, '関門 2', False)}` を打つ" + ) + + +def normal_gate_2_stage(a, prod: str, advise: bool) -> str: + """pace: normal の承認ゲート 2 のステージの文。承認の手順はここの 1 か所に置き、配布の note に重ねない(#1914 の決定 1)。""" + approval = f"issues/approval-{(a.release or {}).get('plugin')}-v{prod}.md" + text = ( + f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら" + "「本番」のステージの command を打つ(差し戻されたら打たない)" + ) + return text + "。" + normal_gate_2(a) if advise else text + + def advise_design_steps(a) -> list[dict]: """助言の MVV 判定(#1400)の mvv・mvv-note のステップ。判定によらず、想定外の失敗でも関門 1 の judge(gate)へ進む。""" state = shlex.quote(str(Path(a.state).resolve())) diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/9-release-prod.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/9-release-prod.json new file mode 100644 index 000000000..fd65a8fa3 --- /dev/null +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/9-release-prod.json @@ -0,0 +1,160 @@ +{ + "フェーズ": "配布(本番)", + "課題": [ + 16, + 17 + ], + "モード": "standard", + "作業場所": "/repo/.worktrees/release/v1.2.0", + "branch": "release/v1.2.0", + "起点": "origin/main", + "規則": "利用者は関門 2 を承認した。run のステップが落ちたら、直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度。タグの重複・権限の不足は stop。", + "上限": 98, + "steps": [ + { + "id": "bump", + "type": "run", + "stage": "配布", + "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0 --base main", + "on_fail": "judge", + "next": "bump-others" + }, + { + "id": "bump-others", + "type": "run", + "stage": "配布", + "cmd": "out=$(python3 /scripts/release-steps.py changed-plugins --plugin foo --decided /repo/issues/approval-foo-v1.2.0.md) || { printf \"%s\\n\" \"$out\"; exit 1; }; printf \"%s\\n\" \"$out\"; printf \"%s\\n\" \"$out\" | python3 -c \"import json,sys; [print(i[\\\"name\\\"], i[\\\"to\\\"]) for i in json.loads(sys.stdin.read().strip().splitlines()[-1])[\\\"items\\\"]]\" | while read -r n to; do python3 /scripts/release-steps.py bump --plugin \"$n\" --to \"$to\" --base main || exit 1; done", + "on_fail": "judge", + "next": "changelog" + }, + { + "id": "changelog", + "type": "run", + "cmd": "python3 /scripts/release-steps.py changelog --version 1.2.0 --prs {queue_pr:check}", + "on_fail": "judge", + "next": "notes" + }, + { + "id": "notes", + "type": "run", + "stage": "配布", + "cmd": "sh -c 'python3 /scripts/release-steps.py notes --version 1.2.0 --prs {queue_pr:check} && git add -A && (git diff --cached --quiet || git commit -q -m \"Release: foo v1.2.0\")'", + "on_fail": "judge", + "next": "snapshot" + }, + { + "id": "snapshot", + "type": "run", + "stage": "配布", + "timeout": 900, + "cmd": "sh -c 'python3 /scripts/release-steps.py run --root . --stage production --version 1.2.0 && git add -A && (git diff --cached --quiet || git commit -q -m \"Release: foo v1.2.0 のトークン消費の記録\")'", + "on_fail": "judge", + "next": "release" + }, + { + "id": "release", + "type": "run", + "stage": "配布", + "timeout": 7920, + "cmd": "python3 /scripts/release-steps.py release --version 1.2.0 --ci-wait 3600 --channel prod --approval /repo/issues/approval-foo-v1.2.0.md", + "on_exit": { + "75": "stop" + }, + "on_fail": "judge", + "next": "record", + "probe": { + "cmd": "python3 /scripts/merged-steps.py probe --head {branch} --head {base} --act" + }, + "gate_next": "end" + }, + { + "id": "record", + "type": "run", + "stage": "配布", + "timeout": 300, + "cmd": "python3 /scripts/release-steps.py record --version 1.2.0 --prs {queue_pr:check}", + "pr_from": "release_pr_url", + "on_fail": "judge-record", + "next": "verify" + }, + { + "id": "verify", + "type": "run", + "stage": "リリース後テスト", + "timeout": 1500, + "cwd": "/repo", + "cmd": "sh -c 'git pull -q --ff-only origin main && python3 /scripts/release-verification-steps.py verify-install --ref main --expect 1.2.0 --runtimes claude'", + "on_fail": "judge", + "next": "cleanup" + }, + { + "id": "cleanup", + "type": "run", + "stage": "後片付け", + "cwd": "/repo", + "cmd": "sh -c 'python3 /scripts/merged-steps.py cleanup $(gh pr list --state merged --limit 30 --json number,headRefName --jq \".[] | select(.headRefName | startswith(\\\"release/v1.2.0\\\")) | .number\") {queue_pr:check}'", + "on_fail": "judge", + "next": "end" + }, + { + "id": "judge", + "type": "judge", + "inputs": [ + "bump", + "bump-others", + "changelog", + "notes", + "snapshot", + "release", + "verify", + "cleanup" + ], + "question": "落ちたステップを直す(fix)か、同じステップをもう一度(retry)か、止める(stop)か。retry なら decision に落ちたステップの id を返す", + "choices": [ + "fix", + "bump", + "bump-others", + "changelog", + "notes", + "snapshot", + "release", + "verify", + "cleanup", + "stop" + ] + }, + { + "id": "fix", + "type": "work", + "kind": "修正", + "inputs": [ + "bump", + "bump-others", + "changelog", + "notes", + "snapshot", + "release", + "verify", + "cleanup" + ], + "prompt": "落ちたステップの出力を読み、原因を直してコミットする(push しない)。直したら次は落ちたステップからやり直す。", + "back_to_failed": true, + "next": "release" + }, + { + "id": "judge-record", + "type": "judge", + "inputs": [ + "record" + ], + "question": "リリース記録の書き込み(record)が落ちた。本番の配布は済んでいる。GitHub の揺れなら record をもう一度、権限の不足・本番のリリースの PR が無いなら止める(stop)", + "choices": [ + "record", + "stop" + ] + } + ], + "base_branch": "main", + "リポジトリ": "/repo", + "記録": "/scripts/projects-sync.sh" +} diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json index 84b35ad58..0b965e080 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json @@ -65,6 +65,19 @@ ], "then_of": "検査", "resume": "python3 /scripts/supervise.py queue /out/sprint/7-release.json --max 3" + }, + { + "wave": 8, + "name": "関門 2", + "gate": "開発版の配布が書いた承認資料(issues/approval-foo-v1.2.0.md)を示して本番への配布の承認を取り、承認されたら「本番」のステージの command を打つ(差し戻されたら打たない)" + }, + { + "wave": 9, + "name": "本番", + "plans": [ + "/out/sprint/9-release-prod.json" + ], + "command": "python3 /scripts/supervise.py queue /out/sprint/9-release-prod.json --max 3" } ] } diff --git a/plugins/ndf/scripts/tests/test_project_mvv_normal.py b/plugins/ndf/scripts/tests/test_project_mvv_normal.py index e15a1662f..ae30c906f 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv_normal.py +++ b/plugins/ndf/scripts/tests/test_project_mvv_normal.py @@ -319,7 +319,8 @@ def test_normal_sprint_with_a_state_puts_the_advice_before_both_gates(tmp_path): manifest, out = new_normal_sprint(tmp_path, "--state", str(state)) assert (manifest["進め方"], manifest["状態"], manifest["ブランチ"]) == ("normal", str(state.resolve()), "sprint/m26") waves = {w["name"]: w for w in manifest["ステージ"]} - assert "--what <要約> --by user --pr <設計 PR>" in waves["関門 1"]["gate"] and "--by user" in waves["配布"]["note"] + assert "--what <要約> --by user --pr <設計 PR>" in waves["関門 1"]["gate"] and "--by user" in waves["関門 2"]["gate"] + assert "note" not in waves["配布"] and "'関門 2' --what" in waves["関門 2"]["gate"] assert "sprint-state.py gate" in out["next"] design = load(waves["設計"]["plans"][0]) ds = steps_of(design) @@ -342,6 +343,7 @@ def test_normal_sprint_with_a_state_puts_the_advice_before_both_gates(tmp_path): assert plan["スプリント状態"] == str(state.resolve()) assert_transitions_exist(plan) assert sum(1 for w in manifest["ステージ"] for p in w.get("plans", []) for s in load(p)["steps"] if s["id"] == "mvv") == 2 + assert not {"mvv", "note", "handoff"} & set(steps_of(load(waves["本番"]["plans"][0]))) # 本番は利用者が答える(#1914 の I4) def test_normal_sprint_without_a_state_is_unchanged(tmp_path): diff --git a/plugins/ndf/scripts/tests/test_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index 6fce3e650..cce9a68b7 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -1487,7 +1487,17 @@ def test_new_sprint_writes_waves_in_order(tmp_path): assert res["status"] == "ok" manifest = json.loads((out / "sprint.json").read_text()) assert manifest["ブランチ"] == "sprint/v10-18" - assert [w["name"] for w in manifest["ステージ"]] == ["設計", "関門 1", "設計の結果", "スプリントブランチ", "実装", "検査", "配布"] + assert [w["name"] for w in manifest["ステージ"]] == [ + "設計", + "関門 1", + "設計の結果", + "スプリントブランチ", + "実装", + "検査", + "配布", + "関門 2", + "本番", + ] waves = {w["name"]: w for w in manifest["ステージ"]} assert "plans" not in waves["関門 1"] and waves["関門 1"]["gate"] assert len(waves["実装"]["plans"]) == 2 and waves["実装"]["command"].endswith("--max 3") @@ -1563,7 +1573,7 @@ def test_new_sprint_without_design_skips_gate(tmp_path): p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) assert p.returncode == 0, p.stderr names = [w["name"] for w in json.loads((out / "sprint.json").read_text())["ステージ"]] - assert names == ["スプリントブランチ", "実装", "検査", "配布"] + assert names == ["スプリントブランチ", "実装", "検査", "配布", "関門 2", "本番"] @pytest.mark.parametrize("args", [["--issue", "1"], ["--name", "a b", "--issue", "1"], ["--name", "m"], ["--name", "m", "--issue", "1"]]) @@ -2449,6 +2459,48 @@ def test_new_sprint_check_and_release_run_without_a_whole_skill(tmp_path): assert "command" not in waves["配布"] and waves["配布"]["then_of"] == "検査" +def test_normal_sprint_puts_gate_2_and_prod_after_the_dev_release(tmp_path): + """normal の雛形の経路は配布の後に関門 2 と本番を置き、本番は承認の後に単独で流す(#1914)。""" + out = tmp_path / "m" + p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + assert p.returncode == 0, p.stderr + index = json.loads((out / "sprint.json").read_text())["ステージ"] + assert [w["name"] for w in index][-3:] == ["配布", "関門 2", "本番"] + waves = {w["name"]: w for w in index} + gate, prod = waves["関門 2"], waves["本番"] + assert "plans" not in gate and "approval-" in gate["gate"] and "sprint-state.py" not in gate["gate"] + assert "then_of" not in prod and prod["plans"][0].endswith("-release-prod.json") + assert prod["command"].endswith(f"queue {prod['plans'][0]} --max 3") + assert prod["plans"][0] not in waves["検査"]["command"] and "note" not in waves["配布"] + plan = json.loads(Path(prod["plans"][0]).read_text()) + steps = {s["id"]: s for s in plan["steps"]} + assert "--channel prod" in steps["release"]["cmd"] and "--version 10.18.0 " in steps["release"]["cmd"] + assert "--prs {queue_pr:check}" in steps["changelog"]["cmd"] and plan["課題"] == [1] + assert not {"mvv", "note", "handoff"} & set(steps) + + +def test_prod_plan_queued_alone_takes_the_check_pr(tmp_path): + """関門 2 の後に本番のプランだけを流すと、同じディレクトリの検査の報告から PR が埋まる(#1914 の AC3)。""" + from supervise_lib import paths, queue + + out = tmp_path / "m" + p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + assert p.returncode == 0, p.stderr + waves = {w["name"]: w for w in json.loads((out / "sprint.json").read_text())["ステージ"]} + check, prod = waves["検査"]["plans"][0], waves["本番"]["plans"][0] + alone = tmp_path / "alone" + alone.mkdir() + for f in (check, prod): + (alone / Path(f).name).write_text(Path(f).read_text()) + d = paths.state_dir_of(str(alone / Path(check).name)) + d.mkdir(parents=True, exist_ok=True) + (d / "report.md").write_text("## フェーズの報告\n\n- 結果: 完了\n- Pull Request: 4242\n") + target = alone / Path(prod).name + assert queue.fill_queue_pr(str(target), []) is None + text = target.read_text() + assert "{queue_pr:check}" not in text and "--prs 4242" in text + + def test_new_impl_with_several_issues_refers_to_all_of_them(tmp_path): out = tmp_path / "p.json" p = cli("new", "impl", "--issue", "898", "913", "922", "--worktree", "/w", "--tests", "t.py", "--title", "T", "--out", str(out)) diff --git a/plugins/ndf/scripts/tests/test_supervise_new.py b/plugins/ndf/scripts/tests/test_supervise_new.py index 84d53e63d..db1633103 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -92,6 +92,18 @@ def test_new_sprint_with_package_plugin_keeps_release_stage(tmp_path): assert p.returncode == 0, p.stderr names = [w["name"] for w in json.loads((out / "sprint.json").read_text())["ステージ"]] assert names[-1] == "配布" and "/ndf:release" not in json.loads(p.stdout)["next"] + assert "production_branch" in json.loads(p.stdout)["next"] # 本番のブランチが無いので本番を置かない(#1914 の決定 3) + + +def test_normal_sprint_without_a_production_branch_keeps_gate_2_on_the_release(tmp_path): + root = plain_repo(tmp_path, {"form": "package-plugin", "plugin": "foo", "runtimes": ["claude"]}) + state = tmp_path / "state.json" + state.write_text(json.dumps({"name": "m", "pace": "normal", "gates": []})) + out = tmp_path / "m" + p = new_sprint(root, out, "--state", str(state)) + assert p.returncode == 0, p.stdout + p.stderr + last = json.loads((out / "sprint.json").read_text())["ステージ"][-1] + assert last["name"] == "配布" and "--by user" in last["note"] def close_cli(root, out, *extra): From b68a1cf7526992b3fe22d4f5f53dc75eb0303a28 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 01:25:08 +0000 Subject: [PATCH 02/35] =?UTF-8?q?Docs:=20normal=20=E3=81=AE=E6=89=BF?= =?UTF-8?q?=E8=AA=8D=E3=82=B2=E3=83=BC=E3=83=88=202=20=E3=81=AE=E5=BE=8C?= =?UTF-8?q?=E3=81=AF=E7=9B=AE=E9=8C=B2=E3=81=AE=E6=9C=AC=E7=95=AA=E3=81=AE?= =?UTF-8?q?=E3=82=B9=E3=83=86=E3=83=BC=E3=82=B8=E3=82=92=E6=89=93=E3=81=A4?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88#1914?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit waiting.md の手順 8・9、agent-layers.md の仕上げ・リリースの行、relay.md の例を、 new sprint が書き出す「関門 2」と「本番」のステージに合わせた。本番のプランを new release --channel prod で手で作る記述は、単発のリリースの説明だけに残す。 Co-Authored-By: Claude Opus 5.5 --- .../skills/development-workflow/references/agent-layers.md | 4 ++-- .../ndf/skills/development-workflow/references/relay.md | 6 +++--- .../ndf/skills/development-workflow/references/waiting.md | 7 ++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/plugins/ndf/skills/development-workflow/references/agent-layers.md b/plugins/ndf/skills/development-workflow/references/agent-layers.md index 5732af53d..156452ceb 100644 --- a/plugins/ndf/skills/development-workflow/references/agent-layers.md +++ b/plugins/ndf/skills/development-workflow/references/agent-layers.md @@ -82,8 +82,8 @@ context window を埋めるときは、**読解だけを worker へ出す**(co | 実装 | `new sprint` の実装のプラン(課題ごと。スプリントブランチ宛て)。スプリントブランチはその前のステージのプランが切る。単発は `new impl` | スプリントブランチ → 実装 | — | | 検査 | `new sprint` の検査のプラン(スプリントの Pull Request を出し、構造改善・コードレビュー・完了判定を通してマージする)。単発は `new check`、`pace: fast` は `new check --since-last` | 検査 | — | | 取り込み | スプリントの Pull Request のマージは検査のプランが、検証リリースは開発版のリリースプランが持つ(後片付けは本番のリリースプランの最後のステップ) | 配布(検査のキューが `--then` で流す) | 確定仕様化(`plan-to-spec`)。worker へ委ねるときは全課題を 1 件の worker に委ね、起動指示の「手順」へ `plan-to-spec` の抜粋(`references/excerpt.md`)をコピーする。`normal` のプランはこの工程を持たない(`pace: fast` は `new close` が持つ) | -| 仕上げ | 本番のリリースプラン `new release --channel prod`(本番へのリリース・リリース後テスト・後片付け) | 目録(`/sprint.json`)のステージの外。ゲート 2 の承認の後に流す | 振り返り(`retrospective`)と棚卸し(`backlog-refinement`)。同じ supervisor が振り返りに続けて棚卸しを通す。`normal` のプランはこの 2 つの工程を持たない(`pace: fast` は `new close` が持つ) | -| リリース | `new release --channel dev` / `--channel prod` | 配布 | —(プランだけが通す) | +| 仕上げ | `new sprint` の本番のリリースプラン(本番へのリリース・リリース後テスト・後片付け)。単発は `new release --channel prod` | `関門 2` → `本番`(ゲート 2 の承認の後に、`本番` のステージの `command` を単独で流す) | 振り返り(`retrospective`)と棚卸し(`backlog-refinement`)。同じ supervisor が振り返りに続けて棚卸しを通す。`normal` のプランはこの 2 つの工程を持たない(`pace: fast` は `new close` が持つ) | +| リリース | `new sprint` の開発版と本番のリリースプラン。単発は `new release --channel dev` / `--channel prod` | 配布・本番 | —(プランだけが通す) | **supervisor へ落とすのは、次の 2 つのときだけである。** diff --git a/plugins/ndf/skills/development-workflow/references/relay.md b/plugins/ndf/skills/development-workflow/references/relay.md index 1cb9931fb..80e4a1ded 100644 --- a/plugins/ndf/skills/development-workflow/references/relay.md +++ b/plugins/ndf/skills/development-workflow/references/relay.md @@ -356,14 +356,14 @@ export NDF_SUPERVISE_CLAUDE_FALLBACK='CLAUDE_CODE_USE_BEDROCK=1 AWS_PROFILE=<名 - `sprint-state.py next --doc <引継ぎ文書> --replace`(「次に実行するコマンド」の節を置き換え、同じ `ndf-next` のブロックを最後の応答に出す) - `handoff.py check <名> --trim`(「前の会話の進み」を履歴へ移し、節の形と 300 行の上限を確かめる。1 か 5 のときの扱いは [handoff.md](handoff.md) の「作る・更新する」) -**本番へのリリースの後は、カットポイントの呼び出しを本番のキューと同じバックグラウンドの Bash で続けて流す。** 流す前に conductor が `handoff.py init` を打ち、本番の後の「現在地」と「次にやること」を文書へ書く(パイプラインの中では書き直せない)。`check` が 1 か 5 ならパイプラインはブロックを出さずに止まり、conductor が完了の通知で終了コードを受けて扱ってからブロックを出す。ゲート 2 の承認から次のセッションの起動までに、conductor が組み立てる文は無くなる。conductor は完了の通知を受けたら `relay.py notice` のアナウンスと、出力の `ndf-next` のブロックをそのまま出す。ラッパーがプラグインを本番の版へ更新し、次のセッションを起動する。 +**本番へのリリースの後は、カットポイントの呼び出しを本番のキューと同じバックグラウンドの Bash で続けて流す。** 流す前に conductor が `handoff.py init` を打ち、本番の後の「現在地」と「次にやること」を文書へ書く(パイプラインの中では書き直せない)。`check` が 1 か 5 ならパイプラインはブロックを出さずに止まり、conductor が完了の通知で終了コードを受けて扱ってからブロックを出す。ゲート 2 の承認から次のセッションの起動までに、conductor が組み立てる文は無くなる。conductor は完了の通知を受けたら `relay.py notice` のアナウンスと、出力の `ndf-next` のブロックをそのまま出す。ラッパーがプラグインを本番の版へ更新し、次のセッションを起動する。本番のキューは、目録(`$O/sprint.json`)の `本番` のステージの `command` に `--done` を足したものである(例の `9` はステージの番号)。 ```bash O=~/.local/state/ndf/sv/r7; M=plugins/ndf/scripts/sprint-state.py; H=plugins/ndf/scripts/handoff.py; N=sprint-<スプリント名> DOC=$(python3 $H path $N | python3 -c 'import json, sys; print(json.load(sys.stdin)["items"][0]["path"])') python3 $H init $N --title "<表示名>" >/dev/null && -python3 plugins/ndf/scripts/supervise.py queue $O/plan-release-prod.json --done $O/done-release-prod.json >/dev/null && -python3 plugins/ndf/scripts/supervise.py wait $O/done-release-prod.json --timeout 3600 >/dev/null && +python3 plugins/ndf/scripts/supervise.py queue $O/9-release-prod.json --max 3 --done $O/done-9.json >/dev/null && +python3 plugins/ndf/scripts/supervise.py wait $O/done-9.json --timeout 3600 >/dev/null && python3 $M update $O/sprint-state.json >/dev/null && python3 $M render $O/sprint-state.json $DOC --demote 前の会話の進み --heading "今の会話の進み($(date -u +%Y-%m-%d\ %H:%M) UTC まで)" >/dev/null && python3 $M next $O/sprint-state.json --doc $DOC --replace >/dev/null && diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md index fadbf2a4d..f85904084 100644 --- a/plugins/ndf/skills/development-workflow/references/waiting.md +++ b/plugins/ndf/skills/development-workflow/references/waiting.md @@ -164,9 +164,10 @@ conductor が起きるのは、承認ゲート・`attention`・キューの終 `sv wait $O/done-6.json`。検査のプランがスプリントの Pull Request をベースブランチへマージし、開発版のリリースプランが続けて流れる。 スプリントの Pull Request の本文に「手動確認」のマージ前の行があれば、印が付くまで `merge-gate` が承認ゲートで止まる (利用者が印を付けたら `sv run $O/6-check.json --from merge-gate`) -8. 承認ゲート 2: キューの結果が `gate`(開発版の facts のステップ)なら、承認資料を添えて本番の承認を取る -9. 本番: `sv new release --version 10.18.0 --prs <スプリントの PR 番号> --channel prod --worktree <リポジトリの根>/.worktrees/release/v10.18.0 --out $O/8-release-prod.json`、 - 続けて `sv queue $O/8-release-prod.json --done $O/done-8.json` と `sv wait $O/done-8.json`。最後のステップが後片付けを行う +8. 承認ゲート 2: キューの結果が `gate`(開発版の facts のステップ)なら、`sprint.json` の `関門 2` のステージの `gate` に沿って + 承認資料を添えて本番の承認を取る。差し戻されたら 9 を打たない +9. 本番: `sprint.json` の `本番` のステージの `command` に `--done` を足して打つ(`sv queue $O/9-release-prod.json --max 3 --done $O/done-9.json`)と + `sv wait $O/done-9.json`。出す PR は検査の報告から埋まる。最後のステップが後片付けを行う - ステージの番号とプランのファイル名は `new sprint` の出力(`sprint.json` の `ステージ`)が正である。書き出した `command` に `--done` を足して打つ - 確定仕様化・振り返り・棚卸しは `normal` のプランが持たないため、supervisor で回す([agent-layers.md](agent-layers.md) の表の取り込み・仕上げの行) From e6d34cd84fda63230eb02e0901e77e97653901a5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 01:45:54 +0000 Subject: [PATCH 03/35] =?UTF-8?q?fix(ndf):=20fast=20/=20auto=20=E3=81=AE?= =?UTF-8?q?=E6=98=87=E6=A0=BC=E3=81=AF=E3=80=81=E5=B0=8E=E5=85=A5=E3=81=AE?= =?UTF-8?q?=E7=A2=BA=E8=AA=8D=E3=81=A7=E7=A2=BA=E3=81=8B=E3=82=81=E3=81=9F?= =?UTF-8?q?=E3=82=B3=E3=83=9F=E3=83=83=E3=83=88=E3=81=AE=E3=81=A8=E3=81=8D?= =?UTF-8?q?=E3=81=A0=E3=81=91=E3=83=9E=E3=83=BC=E3=82=B8=E3=81=99=E3=82=8B?= =?UTF-8?q?=EF=BC=88#1529=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merged-steps.py promote に --expect-head-from <導入の確認の承認資料> を足し、資料の確かめたコミットを merge-when-green の --expect-head と同じ照合に使う。読めなければ gh を呼ばずに 2 で止まる。 昇格のプランの fast / auto の promote は、mvv と同じ VERIFY_MATERIAL を渡し、落ちたら handoff で承認ゲート 2 へ戻る。 確かめたコミットの行の形と読み取りは lib/approved_commit.py の verified_line / verified_sha に置き、verify_markdown もそれで書く。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/approved_commit.py | 28 +++++++- plugins/ndf/scripts/merged-steps.py | 13 +++- plugins/ndf/scripts/merged_lib/merge.py | 13 +++- plugins/ndf/scripts/release_lib/deploy.py | 3 +- .../supervise_lib/delivery_templates.py | 5 +- .../ndf/scripts/tests/test_approved_commit.py | 26 +++++++ .../tests/test_sprint_close_merge_green.py | 72 +++++++++++++++++++ .../tests/test_supervise_pace_verify.py | 52 ++++++++++++++ 8 files changed, 204 insertions(+), 8 deletions(-) diff --git a/plugins/ndf/scripts/lib/approved_commit.py b/plugins/ndf/scripts/lib/approved_commit.py index 4f78cc3f9..32136ee99 100644 --- a/plugins/ndf/scripts/lib/approved_commit.py +++ b/plugins/ndf/scripts/lib/approved_commit.py @@ -2,10 +2,11 @@ - 承認資料(`release-steps.py approval-facts` が書く Markdown)の欄の名前・SHA の形の検査・読み取り・承認の記録の書き込み - 承認したコミットから比べた先端までに、許したもの(配布の PR)の外のコミットや中身が無いかの比較(`compare_approved`) +- 導入の確認の承認資料(`release-steps.py verify-facts` が書く)の、確かめたコミットの行の形と読み取り + (`verified_line` / `verified_sha`。昇格の経路 `merged-steps.py promote --expect-head-from` が読む。#1529) `compare_approved` は git を読むだけで書かず、終了コードも出力も持たずに判定(`Verdict`)を値で返す。読み替えは呼び手が行う。 -ブランチ名は受け取らず SHA だけを受け取る。`allowed` を空にすると、先端の木が承認したコミットの木と等しいかの判定になる -(昇格の経路 `merged-steps.py promote` から使う形)。 +ブランチ名は受け取らず SHA だけを受け取る。`allowed` を空にすると、先端の木が承認したコミットの木と等しいかの判定になる。 """ from __future__ import annotations @@ -153,6 +154,29 @@ def material_row(sha: str) -> tuple[str, str]: return (ROW, parse_sha(sha)) +# --- 確かめたコミット(導入の確認の承認資料。#1529) ------------------------------ + +_VERIFIED_RE = re.compile(r"^- ベースブランチ \S+ の先頭: `([^`]*)`。導入の確認はこのコミットで走らせた$") + + +def verified_line(base: str, sha: str) -> str: + """導入の確認の承認資料(`release-steps.py verify-facts`)の、確かめたコミットの行。""" + return f"- ベースブランチ {base} の先頭: `{sha}`。導入の確認はこのコミットで走らせた" + + +def verified_sha(path) -> str: + """導入の確認の承認資料から確かめたコミットを読む(`merged-steps.py promote --expect-head-from`)。 + 資料が無い・行が無い・行が 2 つ以上・40 桁の 16 進でなければ ValueError(照合を飛ばさない)。""" + p = Path(path) + if not p.is_file(): + raise ValueError(f"導入の確認の承認資料 {p} が無い") + found = [m.group(1) for m in (_VERIFIED_RE.match(line) for line in p.read_text(encoding="utf-8").split("\n")) if m] + if len(found) != 1: + what = "無い" if not found else f"{len(found)} 行ある" + raise ValueError(f"導入の確認の承認資料 {p} に確かめたコミットの行が{what}") + return parse_sha(found[0]) + + # --- 比較 --------------------------------------------------------------------- diff --git a/plugins/ndf/scripts/merged-steps.py b/plugins/ndf/scripts/merged-steps.py index 6a4005efc..5e958f5b7 100755 --- a/plugins/ndf/scripts/merged-steps.py +++ b/plugins/ndf/scripts/merged-steps.py @@ -7,6 +7,7 @@ python3 merged-steps.py merge-when-green [--gate-approved user|mvv] [--method merge|squash|rebase] [--interval 秒] [--timeout 秒] [--stale-after 秒] [--no-cleanup] [--expect-head ] [--root ] python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--gate-approved user|mvv] + [--expect-head-from <導入の確認の承認資料>] cleanup: マージ済みの PR の作業ツリーとローカルブランチを外し、主ディレクトリを取り込む。 `git worktree remove` が拒否した作業ツリーは、未追跡・無視されたファイルを退避してから外す。作り直せる生成物 @@ -20,7 +21,11 @@ CI を待たずに承認ゲート 2 で止まる(status: gate・metrics.gate: production-merge。判定は lib/delivery.py。#1336)。 merge-gate: 宛先の判定だけを行う(0 = 進めてよい / 10 = 承認ゲート 2)。 promote: 昇格の Pull Request(ベースブランチ → 本番チャネル)を探すか作り、merge-when-green と同じ判定と待ちで -マージする(後片付けはしない)。--prepare は用意して承認資料を書くところで 0 で終える。 +マージする(後片付けはしない)。--prepare は用意して承認資料を書くところで 0 で終える。--expect-head-from に +導入の確認の承認資料(`release-steps.py verify-facts` が書く)を渡すと、そこに書かれた確かめたコミットを --expect-head と +同じ照合に使い、昇格の Pull Request の先端がそのコミットのときだけマージする(違えば items[].result: head_moved で 1。 +資料が無い・確かめたコミットを読めなければ gh を呼ばずに 2)。fast / auto の昇格のプランはこれで止まると handoff で +承認ゲート 2 へ戻る(#1529)。 実行が終わったのにチェックが pending のまま --stale-after 秒続けば、そのジョブを 1 度だけ `gh run rerun --job` で再実行し、再実行でも取り残されれば止まる。実行が終わりジョブに結論があれば、チェックの表示が pending のままでも待たずにその結論で扱う。ジョブがランナーを待つ間は、待ち行列の件数を待ちの 1 周ごとに stderr へ 1 行出す。 @@ -479,6 +484,12 @@ def build_parser(): pm.add_argument("--base", required=True, help="本番チャネル") pm.add_argument("--prepare", action="store_true", help="Pull Request を用意して承認資料を書くところで終える(マージしない)") pm.add_argument("--out", help="--prepare の承認資料の置き場(既定は提示物の置き場)") + pm.add_argument( + "--expect-head-from", + metavar="承認資料", + help="導入の確認の承認資料(verify-facts が書く)。確かめたコミットが PR の先端のときだけマージする" + "(違えば result: head_moved で止まる。読めなければマージせずに 2)", + ) merge.add_wait_args(pm) pm.set_defaults(func=cmd_promote) pr = sub.add_parser( diff --git a/plugins/ndf/scripts/merged_lib/merge.py b/plugins/ndf/scripts/merged_lib/merge.py index 4f53d7c86..1b3168d42 100644 --- a/plugins/ndf/scripts/merged_lib/merge.py +++ b/plugins/ndf/scripts/merged_lib/merge.py @@ -10,11 +10,12 @@ import json +import approved_commit import delivery import gh_parts import manual_checks from merged_lib.checks import TOOL, GreenWatch, pr_state -from step_result import StepError, approval_present, emit, git_root, result +from step_result import EXIT_UNREADABLE, StepError, approval_present, emit, git_root, result def add_wait_args(m): @@ -299,7 +300,15 @@ def _prepare_promote(root, a, n, item): def promote(a, cleanup): """ベースブランチ(--head)から本番チャネル(--base)への昇格の Pull Request を作り(あれば使い)、承認ゲート 2 の後にマージする。 - head はベースブランチなので、マージの後に後片付けをしない(#1336 の I7)。""" + head はベースブランチなので、マージの後に後片付けをしない(#1336 の I7)。 + --expect-head-from があれば、最初に導入の確認の承認資料から確かめたコミットを読み、merge-when-green の + --expect-head と同じ照合に使う。読めなければ gh を呼ばずに EXIT_UNREADABLE で止まる(照合を飛ばさない。#1529)。""" + src = getattr(a, "expect_head_from", None) + if src: + try: + a.expect_head = approved_commit.verified_sha(src) + except ValueError as e: + raise StepError(f"確かめたコミットを読めないため昇格しない: {e}", EXIT_UNREADABLE) from e root = git_root(a.root) n, created = _find_or_create_promote_pr(root, a.head, a.base) item = {"kind": "pr", "name": f"#{n}", "result": "created" if created else "found", "base": a.base, "head_branch": a.head} diff --git a/plugins/ndf/scripts/release_lib/deploy.py b/plugins/ndf/scripts/release_lib/deploy.py index 5088ce196..0bab01fac 100644 --- a/plugins/ndf/scripts/release_lib/deploy.py +++ b/plugins/ndf/scripts/release_lib/deploy.py @@ -23,6 +23,7 @@ import subprocess from pathlib import Path +import approved_commit import delivery import proc from step_result import EXIT_PRECONDITION, EXIT_UNREADABLE, StepError, emit, git_root, result @@ -68,7 +69,7 @@ def verify_markdown(base: str, sha: str, verify: str, code: int) -> str: "", "## 確認したコミット", "", - f"- ベースブランチ {base} の先頭: `{sha}`。導入の確認はこのコミットで走らせた", + approved_commit.verified_line(base, sha), "", *verify_lines(verify, code), ] diff --git a/plugins/ndf/scripts/supervise_lib/delivery_templates.py b/plugins/ndf/scripts/supervise_lib/delivery_templates.py index 7da01cff5..4ee4eb5f9 100644 --- a/plugins/ndf/scripts/supervise_lib/delivery_templates.py +++ b/plugins/ndf/scripts/supervise_lib/delivery_templates.py @@ -68,7 +68,8 @@ def plan_promote( - normal(`mvv` 無し): promote(承認の引数無し。承認ゲート 2 で終える)→ promote-approved(`--from` でだけ入る) - fast / auto(`mvv` にスプリントの状態): verify(導入の確認。落ちたら止まる。#1457)→ prepare(PR と承認資料)→ mvv(承認ゲート 2 の MVV 判定)→ note(判定のコメント)→ - promote(`--gate-approved mvv`)。note か promote が落ちたら handoff が承認ゲートへ落とす + promote(`--gate-approved mvv`)。promote は mvv と同じ導入の確認の承認資料(VERIFY_MATERIAL)を `--expect-head-from` に受け、 + 確かめたコミットのときだけマージする(#1529)。note か promote が落ちたら handoff が承認ゲートへ落とす """ head, base = a.base, production or a.production_branch if not base: @@ -139,7 +140,7 @@ def plan_promote( "type": "run", "stage": "配布", "timeout": timeout, - "cmd": promote + wait + " --gate-approved mvv", + "cmd": promote + wait + f" --expect-head-from {VERIFY_MATERIAL} --gate-approved mvv", "on_fail": "handoff", "gate_next": "end", "next": "record", diff --git a/plugins/ndf/scripts/tests/test_approved_commit.py b/plugins/ndf/scripts/tests/test_approved_commit.py index c3c43e8ad..22c68d148 100644 --- a/plugins/ndf/scripts/tests/test_approved_commit.py +++ b/plugins/ndf/scripts/tests/test_approved_commit.py @@ -192,3 +192,29 @@ def test_material_without_the_row_is_value_error(tmp_path): assert not isinstance(e.value, ac.Unapproved) with pytest.raises(ValueError): ac.material_sha(tmp_path / "none.md") + + +# --- 確かめたコミット(導入の確認の承認資料。#1529) -------------------------------- + + +def test_verified_sha_reads_what_verify_markdown_wrote(tmp_path: Path): + """#1529 の I3: verify-facts の承認資料の行は verified_line が書き、verified_sha が同じ SHA を読む。""" + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from release_lib.deploy import verify_markdown + + sha = "0123456789abcdef" * 2 + "01234567" + text = verify_markdown("trunk", sha.upper(), "true", 0) + assert ac.verified_line("trunk", sha.upper()) in text.split("\n") + p = tmp_path / "approval-verify.md" + p.write_text(text) + assert ac.verified_sha(p) == sha + + +@pytest.mark.parametrize("text", [None, "# 無い\n", "{l}\n{l}\n", "{short}\n"]) +def test_verified_sha_rejects_an_unreadable_material(tmp_path: Path, text): + """#1529 の I2: 資料が無い・行が無い・2 つ以上・40 桁でなければ ValueError。""" + p = tmp_path / "approval-verify.md" + if text is not None: + p.write_text(text.format(l=ac.verified_line("trunk", "a" * 40), short=ac.verified_line("trunk", "a" * 39))) + with pytest.raises(ValueError): + ac.verified_sha(p) diff --git a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py index 75db457a1..5c8db5b4e 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py @@ -935,3 +935,75 @@ def test_merge_gate_stops_on_unchecked_before_merge_rows(repo, gh, body, code): if code == 10: assert out["metrics"]["gate"] == "manual-check" and "実機で承認資料の欄を見る" in out["summary"] assert "--from merge-gate" in out["next"] + + +# --- #1529: fast / auto の昇格は、導入の確認で確かめたコミットのときだけマージする ---------------------- + +X, Y = "a" * 40, "b" * 40 + + +def verified_material(tmp_path, *lines): + """導入の確認の承認資料(verify-facts が書く形)。lines を省けば確かめたコミットを X にする。""" + sys.path.insert(0, str(SCRIPTS)) + from release_lib.deploy import verify_markdown + + p = tmp_path / "approval-verify.md" + p.write_text("\n".join(lines) if lines else verify_markdown("develop", X, "true", 0), encoding="utf-8") + return p + + +def promote_mvv(repo, gh, material): + args = ["promote", "--head", "develop", "--base", "main", "--interval", "0", "--recheck", "0"] + return call("merged-steps.py", [*args, "--expect-head-from", str(material), "--gate-approved", "mvv"], gh.env, repo) + + +def test_promote_merges_the_verified_commit(repo, gh, tmp_path): + """AC1・I1: 先端が確かめたコミットのまま CI が通れば、そのコミットに固定してマージする。""" + declare(repo, *TRYGROUP) + gh.set(open_prs=[{"number": 9}], pr_seq={"9": [dict(to("main", X), headRefName="develop")] * 3}) + code, out, err = promote_mvv(repo, gh, verified_material(tmp_path)) + assert code == 0, (out, err) + assert merges(gh) == [["pr", "merge", "9", "--admin", "--merge", "--match-head-commit", X]] + assert next(i for i in out["items"] if i.get("result") == "merged")["head"] == X + + +@pytest.mark.parametrize( + "seq", + [ + [dict(to("main", Y), headRefName="develop")] * 2, # 確認の後、最初の読みまでに進んだ + [dict(to("main", X), headRefName="develop"), dict(to("main", Y), headRefName="develop")], # CI を待つ間に進んだ + [{"state": "MERGED", "headRefOid": Y, "mergeCommit": {"oid": "c"}, "baseRefName": "main"}], # 別の先端でマージ済み + ], +) +def test_promote_stops_when_the_head_moved_from_the_verified_commit(repo, gh, tmp_path, seq): + """AC2・AC3・I1: 先端が確かめたコミットと違えば gh pr merge を呼ばず、head_moved と両方の SHA を出して 1。""" + declare(repo, *TRYGROUP) + gh.set(open_prs=[{"number": 9}], pr_seq={"9": seq}) + code, out, err = promote_mvv(repo, gh, verified_material(tmp_path)) + assert code == 1 and out["status"] == "stopped", (out, err) + assert {"kind": "pr", "name": "#9", "result": "head_moved", "head": Y, "expected": X} in out["items"] + assert merges(gh) == [] + + +@pytest.mark.parametrize( + "lines", + [ + None, # 資料が無い + ("# 導入の確認の承認資料", "", "- コマンド: `true`"), # 行が無い + (f"- ベースブランチ develop の先頭: `{X}`。導入の確認はこのコミットで走らせた",) * 2, # 行が 2 つ + (f"- ベースブランチ develop の先頭: `{X[:39]}`。導入の確認はこのコミットで走らせた",), # 39 桁 + ], +) +def test_promote_does_not_merge_without_a_readable_verified_commit(repo, gh, tmp_path, lines): + """AC5・I2: 確かめたコミットを読めなければ、gh を 1 度も呼ばずに 2 で止まる(照合を飛ばさない)。""" + declare(repo, *TRYGROUP) + material = tmp_path / "missing.md" if lines is None else verified_material(tmp_path, *lines) + code, out, err = promote_mvv(repo, gh, material) + assert code == 2 and out["status"] == "stopped", (out, err) + assert gh.get().get("calls", []) == [] + + +def test_promote_help_shows_the_verified_commit_argument(gh, repo): + """AC9: promote の --help に照合の引数が出る。""" + p = subprocess.run([PY, str(SCRIPTS / "merged-steps.py"), "promote", "--help"], capture_output=True, text=True, env=gh.env) + assert p.returncode == 0 and "--expect-head-from" in p.stdout diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index 5231a0174..a8164ed53 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -111,6 +111,58 @@ def test_a_failed_verify_stops_before_the_promote_pr(tmp_path): assert ran == [] and res["items"][0]["result"] != "完了", res +@pytest.mark.parametrize("pace", ["auto", "fast"]) +def test_the_promote_merges_only_the_commit_the_mvv_judge_read(tmp_path, pace): + """#1529 の AC4・AC6・I4: fast / auto の promote は mvv の材料と同じ導入の確認の承認資料で照合し、落ちたら handoff へ進む。""" + plan = load(wave(new_sprint(tmp_path, make_repo(tmp_path, [STG, PRD]), pace), "本番")["plans"][0]) + s = steps_of(plan) + material = "{state_dir}/work/approval-verify.md" + assert material in s["verify"]["cmd"] and material in s["mvv"]["cmd"] + assert f"--expect-head-from {material} " in s["promote"]["cmd"] and s["promote"]["on_fail"] == "handoff" + assert "--expect-head-from" not in s["promote-approved"]["cmd"] + + +def test_the_promote_plan_uses_the_declared_branch_names(tmp_path): + """#1529 の AC7・AC8・I5: 照合の引数は fast / auto の promote にだけ付き、ブランチ名は引数のものだけが現れる。""" + from supervise_lib.delivery_templates import plan_promote + + a = type("A", (), {"base": "trunk", "production_branch": "stable", "issue": "11", "mode": "standard"})() + normal = steps_of(plan_promote(a, str(tmp_path), 600, verify="true")) + fast = steps_of(plan_promote(a, str(tmp_path), 600, mvv=str(tmp_path / "state.json"), verify="true")) + base = f"{SCRIPTS / 'merged-steps.py'} promote --head trunk --base stable --timeout 600" + assert normal["promote"]["cmd"].endswith(base) and normal["promote-approved"]["cmd"].endswith(base + " --gate-approved user") + assert fast["promote-approved"]["cmd"] == normal["promote-approved"]["cmd"] + want = base + " --expect-head-from {state_dir}/work/approval-verify.md --gate-approved mvv" + assert fast["promote"]["cmd"].endswith(want) + for s in (*normal.values(), *fast.values()): + assert "develop" not in str(s.get("cmd")) and " main" not in str(s.get("cmd")) + + +def test_a_promote_without_the_verified_commit_falls_back_to_gate_2(tmp_path): + """#1529 の AC4・AC5: 導入の確認の承認資料が無ければ、plan の promote はマージせずに落ち、handoff が + 承認ゲート 2 の by: mvv の記録を外して承認ゲートで終わる。""" + repo = make_repo(tmp_path, [STG, PRD]) + state = sprint_state(tmp_path) + plan = load(wave(new_sprint(tmp_path, repo), "本番")["plans"][0]) + log = tmp_path / "mvv-gate.jsonl" + stub = { + "verify": "true", # 承認資料を書かない + "prepare": "true", + "mvv": f"{PY} {SCRIPTS / 'sprint-state.py'} gate {state} '関門 2' --what x --by mvv --verdict follow --reasons '[]' --log {log}", + "note": "true", + "record": f"touch {tmp_path / 'recorded'}", + } + for s in plan["steps"]: + if s["id"] in stub: + s["cmd"] = stub[s["id"]] + path = tmp_path / "promote.json" + path.write_text(json.dumps(plan, ensure_ascii=False)) + res = queue.cmd_queue([str(path)], 1, poll=0.05) + assert res["status"] == "gate" and res["items"][0]["result"] == "関門", res + assert not (tmp_path / "recorded").exists() + assert not [g for g in load(state)["gates"] if g["name"] == "関門 2"] + + def test_a_passed_verify_keeps_the_order(tmp_path): """AC4: 確認が 0 なら今と同じ順で続き、マージの後にリリース記録を書く(#1870)。""" res, ran = run_promote(tmp_path, "true") From bd48668e208763d16aee381250011c39a4ac5025 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 01:46:16 +0000 Subject: [PATCH 04/35] =?UTF-8?q?docs(ndf):=20pace.md=20=E3=81=AE=E6=98=87?= =?UTF-8?q?=E6=A0=BC=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=81=AB=E3=80=81?= =?UTF-8?q?=E7=A2=BA=E3=81=8B=E3=82=81=E3=81=9F=E3=82=B3=E3=83=9F=E3=83=83?= =?UTF-8?q?=E3=83=88=E3=81=A8=E3=81=AE=E7=85=A7=E5=90=88=E3=81=A8=E6=AD=A2?= =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=9F=E5=BE=8C=E3=81=AE=E7=B6=9A=E3=81=8D?= =?UTF-8?q?=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88#1529=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf/skills/development-workflow/references/pace.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index c97f8b0f0..76e68f93f 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -219,7 +219,11 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの 「本番」(か「導入の確認」)と「リリース」が両方あれば、この順に置く。昇格のプランは `normal` では `promote`(承認の引数無し。 承認ゲート 2 で終える)→ 承認の後に `run <プラン> --from promote-approved`、`fast` / `auto` では `verify`(導入の確認)→ `prepare`(Pull Request と承認資料)→ `mvv`(承認ゲート 2 の MVV 判定)→ `note` → `promote`(`--gate-approved mvv`)で、 -`note` か `promote` が落ちたら `handoff` が承認ゲートへ落とす。`fast` / `auto` の `merge` の経路では開発版のステージを置かない +`note` か `promote` が落ちたら `handoff` が承認ゲートへ落とす。`fast` / `auto` の `promote` は、`mvv` が読んだのと同じ +`approval-verify.md` を `--expect-head-from` に受け、そこに書かれた確かめたコミット(導入の確認を走らせたベースブランチの先頭)が +昇格の Pull Request の先端のときだけマージする。確認の後にベースブランチが進んで先端が違えば(CI を待つ間に進んだときも) +`gh pr merge` を呼ばずに `head_moved` で 1、資料から確かめたコミットを読めなければマージせずに 2 で止まり、どちらも `handoff` が +承認ゲート 2 の `by: mvv` の記録を外して承認ゲートへ落とす。`fast` / `auto` の `merge` の経路では開発版のステージを置かない (ベースブランチへのマージが検証への反映である)。 **`fast` / `auto` の `merge` の経路では、導入の確認(`<節>.verify`)を 1 つのスプリントの中で 1 度だけ走らせる。** 昇格のプランが @@ -251,7 +255,7 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの | 答え | 打つもの | | --- | --- | | 承認(ゲート 1) | `sprint-state.py gate <状態> "関門 1" --what <要約> --by user --outcome approved` → `supervise.py run <設計のプラン> --from approve` → マニフェストのスプリントブランチのステージの `resume` | -| 承認(ゲート 2) | `sprint-state.py gate <状態> "関門 2" --what <要約> --by user --outcome approved` → `supervise.py run <本番のプラン> --from bump`(昇格のプランは `--from promote-approved`、マージの `merge-gate` で止まったプランは `--from merge-approved`。`gate-2` のプランは続きを流さず、手で行う「本番」のステージへ進む) | +| 承認(ゲート 2) | `sprint-state.py gate <状態> "関門 2" --what <要約> --by user --outcome approved` → `supervise.py run <本番のプラン> --from bump`(昇格のプランは `--from promote-approved`。ただし `promote` が照合で止まった(`head_moved`・確かめたコミットを読めない)ときは `--from verify` から流し直す。`promote-approved` は照合を持たず、導入の確認も MVV 判定もしていない今の先端をマージする。マージの `merge-gate` で止まったプランは `--from merge-approved`。`gate-2` のプランは続きを流さず、手で行う「本番」のステージへ進む) | | 差し戻し | `sprint-state.py gate <状態> "関門 N" --what <要約> --by user --outcome rejected`。続きは流さない。差し戻しは状態ファイルでなく差し戻しの記録(`gate-rejections.jsonl`)へ 1 行残る。状態ファイルの無い進め方の打ち方は [approval-request.md](approval-request.md) の「答えを記録する」 | **判定と食い違う答えは覆しとして残る。** 「従う」で自動に通った後の差し戻しは `override_reject`、「従わない」「判定できない」で From 9bf8ff980d9486bcf53135b1da0355fa7fc85b9b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 01:59:59 +0000 Subject: [PATCH 05/35] =?UTF-8?q?feat(cross-refactoring):=20init=20?= =?UTF-8?q?=E3=81=A7=E7=AF=84=E5=9B=B2=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?=E3=83=86=E3=83=B3=E3=83=97=E3=83=AC=E3=83=BC=E3=83=88=E3=82=92?= =?UTF-8?q?=E8=A9=A6=E8=B5=B0=E3=81=97=E3=80=81=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=82=92=E5=8F=97=E3=81=91=E3=81=AA=E3=81=91=E3=82=8C?= =?UTF-8?q?=E3=81=B0=E6=AD=A2=E3=82=81=E3=82=8B=EF=BC=88#1630=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 着手前のテストの後に、scope_command を持つテストの suite ごとにテストのファイルを最大 2 本入れて 1 回走らせる。判定が ok でなければ、当たった suite・今のテンプレート・入れたパス・直す箇所を出し、 状態ファイルを作らずに終了コード 4 で止まる。記録は /template-check.json と状態ファイルの template_checks に残す。 - test_strategy: probe_paths / template_verdict / ProbeVerdict(選び方と判定の純粋な関数) - refactor_lib/template_check.py: 候補の収集・実行・記録(新設) - commands/setup.py: _verify_init から呼び、記録を状態へそのまま置く - 既存の init のテストは試走を対象なしとして通し、試走は test_template_check.py で見る Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/test_strategy.py | 109 +++++++++ .../scripts/tests/test_lib_test_strategy.py | 70 ++++++ .../scripts/refactor_lib/commands/setup.py | 10 +- .../scripts/refactor_lib/template_check.py | 208 ++++++++++++++++++ .../cross-refactoring/tests/test_init.py | 9 + .../tests/test_template_check.py | 180 +++++++++++++++ 6 files changed, 581 insertions(+), 5 deletions(-) create mode 100644 plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py create mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_template_check.py diff --git a/plugins/ndf/scripts/lib/test_strategy.py b/plugins/ndf/scripts/lib/test_strategy.py index fe8fd9c8a..4e953ba51 100644 --- a/plugins/ndf/scripts/lib/test_strategy.py +++ b/plugins/ndf/scripts/lib/test_strategy.py @@ -343,6 +343,115 @@ def scope_runs(strategy: Strategy, targets: list[str], changed: list[str]) -> li return runs +# ---------- 範囲テストのテンプレートの試走(#1630) ---------- + +# 判定(`template_verdict`)。`ok` だけが続ける(#1630 の I2)。 +PROBE_OK = "ok" +PROBE_DEFECT = "defect" +PROBE_UNDECIDED = "undecided" +# 候補を選んだ場所(`probe_paths`)。上から順に、最初に 1 本以上見つかった場所を使う(#1630 の I3)。 +PROBE_FROM_SCOPE = "scope" +PROBE_FROM_REPO = "repo" +PROBE_FROM_PATHS = "paths" +# 試走に入れるテストのファイルの最大の本数。1 本は受けて 2 本目を受けない雛形(`discover -s a b`)を見分ける。 +PROBE_MAX_FILES = 2 + + +@dataclass +class ProbePick: + """試走に入れるパス(0〜2 本)と、選んだ場所、その suite が受け持つと言い切れるか(`owned`)。""" + + paths: list[str] + source: Optional[str] + owned: bool + + +@dataclass +class ProbeVerdict: + """試走の判定。`status` は `ok` / `defect` / `undecided`。`ok` 以外は `init` を止める。""" + + status: str + reason: str + + @property + def stops(self) -> bool: + return self.status in (PROBE_DEFECT, PROBE_UNDECIDED) + + +def _declares_whole(suite: Suite) -> bool: + """suite の `paths` が `.` か空(`suite_for` が全てのファイルを当てる)か。""" + return not suite.paths or any(str(p).strip().rstrip("/") in (".", "") for p in suite.paths) + + +def probe_roots(suite: Suite) -> list[str]: + """suite の `paths` のうち、配下の追跡ファイルを候補にしてよい要素(`.` と glob を除く)。""" + roots = [str(p).strip().rstrip("/") for p in suite.paths] + return [r for r in roots if r not in (".", "") and not any(c in r for c in _GLOB_CHARS)] + + +def probe_paths( + strategy: Strategy, + suite: Suite, + scope_files: list[str], + repo_files: list[str], + paths_files: list[str], + failing_files: list[str], +) -> ProbePick: + """試走に入れるテストのファイルを選ぶ(#1630 の I3)。プロセスもファイルも触らない。 + + `scope_files` は `--scope` のテストの置き場所の配下のテストのファイルで、`suite_groups` と同じ規則(`suite_for`、 + 当たらなければ最初のテストの suite)でこの suite に当たるもの。`repo_files` はリポジトリの追跡ファイルのうち + テストの名前の形のもので、`suite_for` がこの suite を返したものだけ(最初の suite へ寄せない)。`paths_files` は + suite の `paths`(`probe_roots`)の配下の追跡ファイル(名前の形を問わない)。どれも `failing_files` + (既存失敗のファイル)を除き、並べ替えた先頭から `PROBE_MAX_FILES` 本を取る。 + + `owned` が偽なのは、`paths` の候補(テストでないファイルが入りうる)と、`paths` が `.` か空の suite の + `repo` の候補(宣言していない置き場所のテストが入りうる)である。""" + failing = {target_path(f) for f in failing_files} + scoped = strategy.scoped_suites(TEST) + first = scoped[0] if scoped else None + + def keep(files: list[str], pick: Any) -> list[str]: + out = sorted({target_path(f) for f in files if target_path(f)} - failing) + return [f for f in out if pick(f)][:PROBE_MAX_FILES] + + own = keep(scope_files, lambda f: (suite_for(strategy, f, TEST) or first) is suite) + if own: + return ProbePick(own, PROBE_FROM_SCOPE, True) + own = keep(repo_files, lambda f: suite_for(strategy, f, TEST) is suite) + if own: + return ProbePick(own, PROBE_FROM_REPO, not _declares_whole(suite)) + own = keep(paths_files, lambda f: True) + if own: + return ProbePick(own, PROBE_FROM_PATHS, False) + return ProbePick([], None, False) + + +def template_verdict( + result: Optional[Outcome], reference: str, failures_known: bool, owned: bool, not_run: str = "候補のファイルが無い" +) -> ProbeVerdict: + """試走の判定(#1630 の判定の表。上の行から当てる)。`result` が `None` なら走らせていない(理由は `not_run`)。 + + `reference` はその suite の着手前のテストの成否(`green` / `red` / `none`)、`failures_known` は既存失敗を読めて + 試走のファイルから除けたか、`owned` は入れたファイルをその suite が受け持つと言い切れるか(`ProbePick.owned`)。""" + if result is None: + return ProbeVerdict(PROBE_UNDECIDED, f"{not_run}ため試走していない") + if result.status == TIMED_OUT: + return ProbeVerdict(PROBE_UNDECIDED, "試走が上限で打ち切られた") + if result.status == PASSED: + return ProbeVerdict(PROBE_OK, "試走が通った") + if result.status != FAILED: + return ProbeVerdict(PROBE_UNDECIDED, f"試走を起動できなかった({result.reason})") + code = f"終了コード {result.code}" + if not owned: + return ProbeVerdict(PROBE_UNDECIDED, f"試走が{code}で落ちたが、入れたファイルをこの suite が受け持つと言い切れない") + if reference == "green": + return ProbeVerdict(PROBE_DEFECT, f"着手前のテストは通り、試走が{code}で落ちた") + if failures_known: + return ProbeVerdict(PROBE_DEFECT, f"既存失敗のファイルを除いた試走が{code}で落ちた") + return ProbeVerdict(PROBE_UNDECIDED, f"着手前のテストが通らず既存失敗も読めないため、試走の{code}の原因を決められない") + + # ---------- 宣言の読み取り ---------- diff --git a/plugins/ndf/scripts/tests/test_lib_test_strategy.py b/plugins/ndf/scripts/tests/test_lib_test_strategy.py index cccef261c..f88a4278d 100644 --- a/plugins/ndf/scripts/tests/test_lib_test_strategy.py +++ b/plugins/ndf/scripts/tests/test_lib_test_strategy.py @@ -599,3 +599,73 @@ def test_covers_whole(paths, locations, expected): def test_covers_whole_needs_a_test_suite(): lint_only = ts.Strategy(ts.LOCAL_SCOPED_CI_WHOLE, "args", [ts.Suite("lint", "ruff .", paths=["."], kind=ts.LINT)]) assert ts.covers_whole(lint_only, ["."]) is False + + +# ---------- #1630: 範囲テストのテンプレートの試走(probe_paths・template_verdict) ---------- + + +def _probe_strategy(*suites): + return ts.Strategy(ts.LOCAL_FULL, "test.strategy", list(suites)) + + +def test_probe_paths_takes_two_scope_files_and_skips_existing_failures(): + """I3: `--scope` の候補から既存失敗のファイルを除き、並べ替えた先頭の 2 本を選ぶ。""" + unit = ts.Suite("unit", "u", "u {paths}", paths=["tests"]) + pick = ts.probe_paths( + _probe_strategy(unit), unit, ["tests/test_c.py", "tests/test_a.py", "tests/test_b.py"], [], [], ["tests/test_a.py"] + ) + assert (pick.paths, pick.source, pick.owned) == (["tests/test_b.py", "tests/test_c.py"], ts.PROBE_FROM_SCOPE, True) + + +def test_probe_paths_falls_back_to_repo_files_the_suite_owns(): + """I3: `--scope` にその suite のテストが無ければ、`suite_for` がその suite を返したものだけを選ぶ(最初の suite へ寄せない)。""" + unit = ts.Suite("unit", "u", "u {paths}", paths=["tests"]) + js = ts.Suite("js", "j", "j {paths}", paths=["web"]) + strategy = _probe_strategy(unit, js) + pick = ts.probe_paths(strategy, unit, [], ["e2e/login.spec.ts", "tests/test_x.py", "web/a.test.ts"], [], []) + assert (pick.paths, pick.source, pick.owned) == (["tests/test_x.py"], ts.PROBE_FROM_REPO, True) + + +def test_probe_paths_from_repo_is_not_owned_when_the_suite_declares_dot(): + """I3: `paths` が `.` の suite には全ファイルが当たるため、`repo` の候補は受け持ちが未確定になる。""" + unit = ts.Suite("unit", "u", "u {paths}", paths=["."]) + pick = ts.probe_paths(_probe_strategy(unit), unit, [], ["e2e/login.spec.ts", "tests/test_x.py"], [], []) + assert (pick.paths, pick.source, pick.owned) == (["e2e/login.spec.ts", "tests/test_x.py"], ts.PROBE_FROM_REPO, False) + + +def test_probe_paths_uses_the_declared_paths_when_no_name_matches(): + """I3: 名前の形に当たらないテストは、suite の `paths` の配下から選ぶ(受け持ちは未確定)。`.` と glob は根にしない。""" + php = ts.Suite("php", "p", "p {paths}", paths=["tests", ".", "src/**/*Test.php"]) + assert ts.probe_roots(php) == ["tests"] + pick = ts.probe_paths( + _probe_strategy(php), php, [], [], ["tests/TestCase.php", "tests/FooTest.php", "tests/CreatesApplication.php"], [] + ) + assert (pick.paths, pick.source, pick.owned) == (["tests/CreatesApplication.php", "tests/FooTest.php"], ts.PROBE_FROM_PATHS, False) + + +def test_probe_paths_is_empty_when_nothing_is_found(): + unit = ts.Suite("unit", "u", "u {paths}", paths=["."]) + pick = ts.probe_paths(_probe_strategy(unit), unit, [], [], [], []) + assert (pick.paths, pick.source, pick.owned) == ([], None, False) + + +@pytest.mark.parametrize( + "result, reference, known, owned, expected", + [ + (None, "green", True, True, ts.PROBE_UNDECIDED), + (ts.outcome(None, True), "green", True, True, ts.PROBE_UNDECIDED), + (ts.outcome(0, False), "none", False, False, ts.PROBE_OK), + (ts.outcome(1, False), "green", True, False, ts.PROBE_UNDECIDED), + (ts.outcome(1, False), "green", False, True, ts.PROBE_DEFECT), + (ts.outcome(5, False), "red", True, True, ts.PROBE_DEFECT), + (ts.outcome(1, False), "none", True, True, ts.PROBE_DEFECT), + (ts.outcome(1, False), "red", False, True, ts.PROBE_UNDECIDED), + (ts.outcome(1, False), "none", False, True, ts.PROBE_UNDECIDED), + ], +) +def test_template_verdict_follows_the_table(result, reference, known, owned, expected): + """I4: 判定の表の行どおりに返し、`ok` 以外は止める。""" + verdict = ts.template_verdict(result, reference, known, owned) + assert verdict.status == expected + assert verdict.stops is (expected != ts.PROBE_OK) + assert verdict.reason 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 d1f8e425a..d32a51b07 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 @@ -12,7 +12,7 @@ import subprocess import sys import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Iterable, Optional import assignment @@ -29,10 +29,7 @@ from .. import ABORT, die, forecast, info, init_test, pause from .. import baseline as baseline_lib -from .. import ci_coverage -from .. import pr_gate -from .. import runtime_decl -from .. import timeline +from .. import ci_coverage, pr_gate, runtime_decl, template_check, timeline from ..paths import ( github_repo_from_origin, default_worktree_base, @@ -462,6 +459,7 @@ class _InitPreparation: strategy: ts.Strategy decl: dict[str, Any] ci_coverage: dict[str, Any] + template_checks: list[dict[str, Any]] = field(default_factory=list) def _resolve_init_inputs(args: argparse.Namespace) -> Optional[_InitInputs]: @@ -601,6 +599,7 @@ def _verify_init( init_test.append_init_test(prep, int(args.budget_minutes), args.pr, baseline) if baseline.get("timed_out"): init_test.abort_timed_out(prep.strategy, int(args.budget_minutes), baseline, timeout) + prep.template_checks = template_check.enforce(args, prep, baseline, timeout) # 不備なら止める(#1630) return participants, baseline, baseline_lib.round_record(prep.strategy, baseline) @@ -669,6 +668,7 @@ def _save_initial_state( # **実行時の値を書き出す**(決定 24)。改修計画の後の値は `merge-plan` が足す。 state["limits"] = timeline.of_state(state, forecast.after_plan(state)) state["ci_coverage"] = prep.ci_coverage + state["template_checks"] = list(prep.template_checks) # 範囲テストのテンプレートの試走の記録(#1630 の決定 7) info(f" 実装担当: {context.implementer}({context.implementer_reason})") # GitHub は自分の Pull Request への `APPROVE` と `REQUEST_CHANGES` を `HTTP 422` で拒む。判定はそのまま結果ファイルへ # 残し、**投稿の event だけ**を倒す。収束判定は結果ファイルの判定を見るので、倒しても進行は変わらない。 diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py new file mode 100644 index 000000000..21db325ce --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py @@ -0,0 +1,208 @@ +"""範囲テストのテンプレートの試走(#1630)。`commands/setup.py` の `init` が着手前のテストの後に呼ぶ。 + +改修の範囲テストは計画の `test_targets`(テストのファイル)をテンプレートの `{paths}` へ入れる。ファイルを受けない +テンプレート(unittest の `discover -s {paths}` など)だと、コードと関係なく毎回落ちて修正へ回る。そこで改修の前に、 +`scope_command` を持つテストの suite ごとにテストのファイルを最大 2 本入れて 1 回走らせ、受けるかを見分ける。 +**判定が `ok` の suite だけが続く。** 不備(`defect`)か判定できない(`undecided`)なら、直す箇所を出して止める。 + +試走のコマンドは改修の範囲テストと同じ `test_strategy.fill` で組む(コマンドの語を見ない)。ファイルの選び方と判定は +`test_strategy.probe_paths` / `template_verdict` が持ち、ここは候補を集めて走らせ、記録を組むだけである。 +記録を組んで `/template-check.json` へ書くのはこのモジュールだけで、`init` は返った記録を状態ファイルの +`template_checks` へそのまま置く。着手前のテストの記録(`baseline_test`)とテストの戦略は書き換えない。 +""" + +from __future__ import annotations + +import json +import pathlib +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +import statefile +import test_strategy as ts +import test_triage + +from . import die, info, launch +from .gitfacts import run_with_timeout +from .scope import test_files_under, test_locations, tracked_files_under + +RECORD_NAME = "template-check.json" +NO_TIME = "着手前のテストの上限の残りが無い" +NO_FILES = "候補のファイルが無い" + + +@dataclass +class Report: + """試走の結果。`stopped` が真なら `message` を出して止める。`suites` は状態ファイルの `template_checks`。""" + + stopped: bool + suites: list[dict[str, Any]] = field(default_factory=list) + message: str = "" + path: Optional[pathlib.Path] = None + + +def targets_of(strategy: ts.Strategy) -> list[ts.Suite]: + """試走の対象の suite。`round-only` は改修の範囲テストを使わないため、静的解析は `{paths}` に変更したファイルが入るため対象にしない。""" + if strategy.name == ts.ROUND_ONLY: + return [] + return strategy.scoped_suites(ts.TEST) + + +def _decl_index(decl: dict[str, Any], suite: ts.Suite) -> Optional[int]: + test = (decl or {}).get("test") + rows = test.get("suites") if isinstance(test, dict) else None + for i, row in enumerate(rows if isinstance(rows, list) else []): + if isinstance(row, dict) and str(row.get("name") or "suite") == suite.name and row.get("scope_command") == suite.scope_command: + return i + return None + + +def _fix_at(strategy: ts.Strategy, decl: dict[str, Any], suite: ts.Suite, flag: Optional[str]) -> tuple[Optional[int], str]: + """直す箇所。宣言の suite は `test.suites[<番号>].scope_command`、引数の雛形は引数の名前。""" + if strategy.source == "args" and suite.name == "args": + return None, f"{flag or '--round-test'} の値" + index = _decl_index(decl, suite) + if index is None: + return None, f".ndf/project.json の test.suites(name: {suite.name})の scope_command" + return index, f".ndf/project.json の test.suites[{index}].scope_command" + + +def _candidates(strategy: ts.Strategy, suite: ts.Suite, scope: list[str], work: pathlib.Path) -> tuple[list[str], list[str], list[str]]: + """`--scope` のテストの置き場所の配下・リポジトリ全体・suite の `paths` の配下の候補(選ぶのは `probe_paths`)。""" + w = str(work) + in_scope = [f for loc in test_locations(scope, w) for f in test_files_under(loc, w)] + repo = test_files_under(".", w) + under = [f for root in ts.probe_roots(suite) for f in tracked_files_under(root, w)] + return in_scope, repo, under + + +def _reference(baseline: dict[str, Any], suite: ts.Suite, source: Optional[str]) -> str: + """基準。`--scope` の候補はその suite の着手前のテストの成否、ほかは全体(`command`)で通ったときだけ `green`。""" + status = (baseline.get("suites") or {}).get(suite.name) + if source == ts.PROBE_FROM_SCOPE: + return status if status in ("green", "red") else "none" + whole = baseline.get("mode") in ("whole", "round") and bool(suite.command) + return "green" if whole and status == "green" else "none" + + +def _check_one( + strategy: ts.Strategy, + suite: ts.Suite, + ctx: dict[str, Any], + budget: tuple[float, float], + i: int, +) -> dict[str, Any]: + baseline, work = ctx["baseline"], ctx["work"] + failures = baseline.get("existing_failures") + failing = list(test_triage.by_file(list(failures or []))) + pick = ts.probe_paths(strategy, suite, *_candidates(strategy, suite, ctx["scope"], work), failing) + index, fix_at = _fix_at(strategy, ctx["decl"], suite, ctx["flag"]) + reference = _reference(baseline, suite, pick.source) + command = ts.fill(str(suite.scope_command), pick.paths) if pick.paths else None + log = ctx["tmp_dir"] / f"template-check-{i}.log" + result: Optional[ts.Outcome] = None + not_run = NO_FILES + if command is not None: + left, started = budget + if left - (time.monotonic() - started) < 1: + not_run = NO_TIME + else: + code, timed_out = test_triage.run_within(left, started, lambda limit: run_with_timeout(command, str(work), limit, output=log)) + result = ts.outcome(code, timed_out) + if result.launch_failed: + launch.stop(None, {}, "baseline", command, result, log) + known = pick.source == ts.PROBE_FROM_SCOPE and failures is not None + verdict = ts.template_verdict(result, reference, known, pick.owned, not_run) + return { + "suite": suite.name, + "index": index, + "fix_at": fix_at, + "scope_command": suite.scope_command, + "paths": list(pick.paths), + "source": pick.source, + "owned": pick.owned, + "command": command, + "code": result.code if result else None, + "outcome": result.status if result else "not_run", + "reference": reference, + "verdict": verdict.status, + "reason": verdict.reason, + "log": str(log) if result else None, + } + + +def stop_text(row: dict[str, Any]) -> str: + """止めるときの文(suite ごとに 1 段)。`undecided` でも直す箇所と `--round-test` の直し方を必ず出す。""" + head = "受けません" if row["verdict"] == ts.PROBE_DEFECT else "受けるかを確かめられません" + if row["code"] is not None: + ran = f"{row['command']} → 終了コード {row['code']}({row['reason']}。着手前のテスト: {row['reference']})" + else: + ran = f"走らせていない({row['reason']})" + ways = ["ファイルを受けるコマンドへ直す", "--round-test に {paths} を含まない全体テストを渡す"] + if not row["paths"]: + ways.append("その suite の paths にテストの置き場所を宣言する") + lines = [ + f"⛔ 範囲テストのテンプレートが {{paths}} に入るテストのファイルを{head}(suite: {row['suite']})", + f" 今のテンプレート: {row['scope_command']}", + f" 入れたパス: {' '.join(row['paths']) or '(なし)'}", + f" 試走: {ran}", + f" 直す箇所: {row['fix_at']}({{paths}} にはテストのファイルのパスが入る。{'か、'.join(ways)})", + ] + if row.get("log"): + lines.append(f" ログ: {row['log']}") + return "\n".join(lines) + + +def run( + strategy: ts.Strategy, + baseline: dict[str, Any], + scope: list[str], + work: pathlib.Path, + tmp_dir: pathlib.Path, + timeout: int, + *, + decl: Optional[dict[str, Any]] = None, + flag: Optional[str] = None, +) -> Report: + """試走の対象の suite ごとに 1 回ずつ走らせ、記録を `/template-check.json` へ書いて返す。 + + 上限は着手前のテストと同じ `timeout` から、着手前のテストの所要(`baseline["seconds"]`)を引いた残りを + suite 群で分け合う。`flag` は雛形を渡した引数の名前(`--round-test` / `--baseline-test`)。""" + suites = targets_of(strategy) + if not suites: + return Report(False) + left = float(timeout) - float(baseline.get("seconds") or 0.0) + budget = (left, time.monotonic()) + ctx = {"baseline": baseline, "work": work, "scope": list(scope), "decl": decl or {}, "flag": flag, "tmp_dir": tmp_dir} + rows = [_check_one(strategy, suite, ctx, budget, i) for i, suite in enumerate(suites)] + # 試走が書いた JUnit を残さない(後の手順が前の実行の結果を読まないため) + test_triage.clear_junit(str(work), strategy) + stopped = any(r["verdict"] != ts.PROBE_OK for r in rows) + path = tmp_dir / RECORD_NAME + path.write_text( + json.dumps({"checked_at": statefile.now(), "stopped": stopped, "suites": rows}, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + message = "\n".join(stop_text(r) for r in rows if r["verdict"] != ts.PROBE_OK) + return Report(stopped, rows, message, path) + + +def summary(report: Report) -> str: + """続けるときの 1 行。""" + shown = "、".join(f"{r['suite']}({' '.join(r['paths'])})" for r in report.suites) + return f"✅ 範囲テストのテンプレートを試走: {shown}" + + +def enforce(args: Any, prep: Any, baseline: dict[str, Any], timeout: int) -> list[dict[str, Any]]: + """`init` の入口。試走して、`ok` でない suite があれば状態ファイルを作らずに止める(終了コード 4。#1630 の I5)。 + 続けるときは状態ファイルの `template_checks` に置く記録を返す。""" + round_test = getattr(args, "round_test", None) + flag = "--round-test" if ts.has_paths(round_test) else "--baseline-test" + report = run(prep.strategy, baseline, list(args.scope), prep.work, prep.tmp_dir, timeout, decl=prep.decl, flag=flag) + if report.stopped: + info(report.message) + die(f"範囲テストのテンプレートを直してから打ち直してください(記録: {report.path})") + if report.suites: + info(summary(report)) + return report.suites diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index a111fd1f8..d3b72d4e2 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -89,6 +89,9 @@ def run_init(refactor_lib, paths, patch_lib, refactor, origin_repo, monkeypatch) refactor_lib = sys.modules["refactor_lib"] probed: list[list[str]] = [] probed_models: list[dict] = [] + # 範囲テストのテンプレートの試走(#1630)はこのファイルの主題ではないため、対象なしとして通す。 + # 試走そのものは `test_template_check.py` で見る。 + _no_template_check(monkeypatch) def _run(args, viewer="someone-else", probe=None, real_probe=False, pr_status=None): """`probe` を渡すと確認を差し替える。`{ランタイム: 理由}` の者だけが通らない。 @@ -164,6 +167,11 @@ def fake_probe(runtimes, *, info, env=None, models=None, level="model"): return _run +def _no_template_check(monkeypatch): + template_check = sys.modules["refactor_lib.template_check"] + monkeypatch.setattr(template_check, "run", lambda *a, **k: template_check.Report(False)) + + def refactor_abort(): """中断の終了コード。`refactor` フィクスチャを取らない関数からも使う。""" return 4 @@ -2019,6 +2027,7 @@ def fake_run(strategy, work, timeout, scope, tmp_dir): monkeypatch.setattr(setup, "resolve_participants", lambda *a, **k: {"available": ["claude"]}) monkeypatch.setattr(setup.baseline_lib, "run_baseline", fake_run) + _no_template_check(monkeypatch) args = types.SimpleNamespace(budget_minutes=30, scope=["tests"], pr=1554, require_all=None) inputs = types.SimpleNamespace(host="claude", include=[], exclude=[], model_spec={}) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_template_check.py b/plugins/ndf/skills/cross-refactoring/tests/test_template_check.py new file mode 100644 index 000000000..1dd46e0dc --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_template_check.py @@ -0,0 +1,180 @@ +"""#1630: `init` が着手前のテストの後に範囲テストのテンプレートを試走し、`{paths}` に入るテストのファイルを受けなければ止まる。 + +再現のリポジトリ(`scripts/storylib/{__init__,x}.py` と `tests/{__init__,test_x,test_y}.py`)を git で作り、宣言から +解いた戦略で `_verify_init` を走らせる。着手前のテストも試走も実際にシェルで走らせる。 +""" + +from __future__ import annotations + +import json +import shlex +import subprocess +import sys +import types + +import pytest + +PY = shlex.quote(sys.executable) +DISCOVER = f"{PY} -m unittest discover -s {{paths}}" +WHOLE = f"{PY} -m unittest discover -s tests" + + +def _git(*args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + +@pytest.fixture +def repo(tmp_path): + work = tmp_path / "work" + for rel, body in { + "scripts/storylib/__init__.py": "", + "scripts/storylib/x.py": "X = 1\n", + # 環境の PYTHONPATH に別の `tests` パッケージがあっても、この `tests` を読むように通常のパッケージにする + "tests/__init__.py": "", + "tests/test_x.py": "import unittest\n\n\nclass T(unittest.TestCase):\n def test_x(self):\n pass\n", + "tests/test_y.py": "import unittest\n\n\nclass T(unittest.TestCase):\n def test_y(self):\n pass\n", + }.items(): + path = work / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + _git("init", "-q", cwd=work) + _git("add", "-A", cwd=work) + _git("-c", "user.email=t@e.st", "-c", "user.name=t", "commit", "-qm", "init", cwd=work) + return work + + +@pytest.fixture +def verify(refactor, monkeypatch, tmp_path, repo): + """宣言を受けて `_verify_init` を呼ぶ。止まれば `(終了コード, None)`、続けば `(None, prep)`。""" + setup = sys.modules["refactor_lib.commands.setup"] + ts = sys.modules["test_strategy"] + monkeypatch.setenv("NDF_METRICS_DIR", str(tmp_path / "metrics")) + monkeypatch.setattr(setup, "resolve_participants", lambda *a, **k: {"available": ["claude"]}) + tmp_dir = tmp_path / "tmp" + tmp_dir.mkdir() + + def call(suite, strategy=None, scope=("scripts/storylib",), round_test=None, extra=()): + decl = {"version": 1, "test": {"suites": [suite, *extra]}} + if strategy: + decl["test"]["strategy"] = strategy + resolved = ts.resolve(decl, round_test=round_test, scope_paths=list(scope)) + prep = types.SimpleNamespace(strategy=resolved, work=repo, tmp_dir=tmp_dir, decl=decl, repo="acme/demo", template_checks=[]) + args = types.SimpleNamespace(budget_minutes=30, scope=list(scope), pr=1630, require_all=None, round_test=round_test) + inputs = types.SimpleNamespace(host="claude", include=[], exclude=[], model_spec={}) + try: + _, baseline, _ = setup._verify_init(args, inputs, prep) + except SystemExit as e: + return e.code, None, None + return None, prep, baseline + + def record(): + return json.loads((tmp_dir / "template-check.json").read_text(encoding="utf-8")) + + return types.SimpleNamespace(call=call, record=record, setup=setup, ts=ts) + + +def _suite(scope_command=DISCOVER, paths=None, **over): + suite = {"name": "unit", "runner": "unittest", "command": WHOLE, "scope_command": scope_command} + if paths is not None: + suite["paths"] = paths + suite.update(over) + return suite + + +def test_a_template_that_rejects_files_stops_with_where_to_fix(verify, capsys): + """受け入れ条件 1・2・3: 全体を走らせる戦略で suite が paths を宣言していれば `defect` で止まり、直す箇所が出る。""" + code, prep, _ = verify.call(_suite(paths=["tests"])) + assert code == 4 and prep is None, "状態ファイルを作る手前で止まる(改修の範囲テストへ進まない)" + err = capsys.readouterr().err + assert "suite: unit" in err and DISCOVER in err + assert "tests/test_x.py tests/test_y.py" in err + assert ".ndf/project.json の test.suites[0].scope_command" in err + row = verify.record()["suites"][0] + assert verify.record()["stopped"] is True + assert (row["verdict"], row["source"], row["reference"], row["index"]) == ("defect", "repo", "green", 0) + assert row["paths"] == ["tests/test_x.py", "tests/test_y.py"] + assert row["command"] == verify.ts.fill(DISCOVER, row["paths"]), "I1: 改修の範囲テストと同じ組み立て" + assert row["code"] == 1 + + +def test_a_suite_that_declares_only_dot_stops_as_undecided(verify, capsys): + """受け入れ条件 1・2: `paths` が `.` なら受け持ちが未確定のため `undecided` で止まり、直す箇所と paths の宣言を出す。""" + code, _, _ = verify.call(_suite()) + assert code == 4 + err = capsys.readouterr().err + assert "受けるかを確かめられません" in err and ".ndf/project.json の test.suites[0].scope_command" in err + assert "--round-test" in err + assert verify.record()["suites"][0]["verdict"] == "undecided" + + +def test_the_ci_strategy_stops_as_undecided_without_junit(verify, capsys): + """受け入れ条件 1: CI に任せる戦略では基準が `none` で、unittest は JUnit を書かないため `undecided` で止まる。""" + code, _, _ = verify.call(_suite(paths=["tests"]), strategy="local-scoped-ci-whole") + assert code == 4 + row = verify.record()["suites"][0] + assert (row["verdict"], row["reference"], row["fix_at"]) == ("undecided", "none", ".ndf/project.json の test.suites[0].scope_command") + + +def test_a_template_that_takes_files_continues_and_keeps_the_record(verify, capsys): + """受け入れ条件 6・I6・決定 7: ファイルを受けるテンプレートは `ok` で続き、記録はそのまま `template_checks` へ置かれる。""" + code, prep, baseline = verify.call(_suite(scope_command=f"{PY} -m unittest {{paths}}", paths=["tests"])) + assert code is None + assert prep.template_checks == verify.record()["suites"] + assert [r["verdict"] for r in prep.template_checks] == ["ok"] + assert "template_checks" not in baseline and baseline["status"] == "green", "着手前のテストの記録は書き換えない" + assert "✅ 範囲テストのテンプレートを試走: unit" in capsys.readouterr().err + + +def test_a_pytest_template_continues(verify): + """受け入れ条件 6: このリポジトリと同じ pytest の雛形は試走が通る。""" + pytest_suite = _suite( + scope_command=f"{PY} -m pytest -q -p no:cacheprovider {{paths}}", command=f"{PY} -m pytest -q -p no:cacheprovider tests" + ) + code, prep, _ = verify.call(pytest_suite, scope=("scripts/storylib", "tests")) + assert code is None + row = prep.template_checks[0] + assert (row["verdict"], row["source"], row["paths"]) == ("ok", "scope", ["tests/test_x.py", "tests/test_y.py"]) + + +def test_lint_and_unscoped_suites_are_not_probed(verify, monkeypatch): + """受け入れ条件 7・I7: 静的解析の suite と scope_command の無い suite は試走しない。試走は suite ごとに 1 回(I2)。""" + template_check = sys.modules["refactor_lib.template_check"] + seen = [] + real = template_check.run_with_timeout + + def counting(command, cwd, timeout, output=None): + seen.append(command) + return real(command, cwd, timeout, output=output) + + monkeypatch.setattr(template_check, "run_with_timeout", counting) + lint = {"name": "lint", "kind": "lint", "command": "true", "scope_command": "false {paths}", "paths": ["."]} + plain = {"name": "plain", "command": "true"} + code, prep, _ = verify.call(_suite(scope_command=f"{PY} -m unittest {{paths}}", paths=["tests"]), extra=(lint, plain)) + assert code is None + assert [r["suite"] for r in prep.template_checks] == ["unit"] + assert seen == [f"{PY} -m unittest tests/test_x.py tests/test_y.py"] + + +def test_no_time_left_stops_without_running(verify, monkeypatch): + """I2: 着手前のテストで上限を使い切ったら走らせずに `undecided` で止まる。""" + setup = verify.setup + real = setup.baseline_lib.run_baseline + monkeypatch.setattr( + setup.baseline_lib, + "run_baseline", + lambda strategy, work, timeout, *a: {**real(strategy, work, timeout, *a), "seconds": float(timeout)}, + ) + code, _, _ = verify.call(_suite(scope_command=f"{PY} -m unittest {{paths}}", paths=["tests"])) + assert code == 4 + row = verify.record()["suites"][0] + assert (row["verdict"], row["outcome"], row["code"]) == ("undecided", "not_run", None) + assert "上限の残りが無い" in row["reason"] + + +def test_an_argument_template_names_the_argument(verify, capsys): + """受け入れ条件 2: `--round-test` の雛形で止まったときは、直す箇所を引数の名前で出す。""" + code, _, _ = verify.call(_suite(paths=["tests"]), round_test=DISCOVER) + assert code == 4 + row = verify.record()["suites"][0] + assert (row["suite"], row["index"], row["fix_at"]) == ("args", None, "--round-test の値") + assert "--round-test の値" in capsys.readouterr().err From b0c9fcfeaff5d176edc2a1f9b3d03739ab22f641 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 02:08:22 +0000 Subject: [PATCH 06/35] =?UTF-8?q?docs(development-workflow):=20{paths}=20?= =?UTF-8?q?=E3=81=AB=E5=85=A5=E3=82=8B=E3=83=91=E3=82=B9=E3=81=AE=E7=A8=AE?= =?UTF-8?q?=E9=A1=9E=E3=81=A8=E3=80=81=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E3=82=92=E5=8F=97=E3=81=91=E3=81=AA=E3=81=84=E3=83=A9=E3=83=B3?= =?UTF-8?q?=E3=83=8A=E3=83=BC=E3=81=AE=E6=9B=B8=E3=81=8D=E6=96=B9=E3=82=92?= =?UTF-8?q?=E5=AE=A3=E8=A8=80=E3=81=AE=E5=A5=91=E7=B4=84=E3=81=AB=E6=9B=B8?= =?UTF-8?q?=E3=81=8F=EF=BC=88#1630=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - project_lib/model.py の Suite の説明に、改修の範囲テスト・CI に任せる戦略の着手前のテスト・静的解析ごとの {paths} に入るパスと、init の試走で止まることを書き、project.schema.json を作り直した - project-analysis.md の test の行に、unittest を例にファイルを受ける scope_command の書き方と、手元で確かめた終了コードを書いた - cross-refactoring の SKILL.md の終了コード 4 の理由に、テンプレートの試走で止まる場合を足した Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/project_lib/model.py | 7 ++++++- plugins/ndf/skills/cross-refactoring/SKILL.md | 2 +- .../development-workflow/references/project-analysis.md | 2 +- .../development-workflow/schemas/project.schema.json | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/plugins/ndf/scripts/project_lib/model.py b/plugins/ndf/scripts/project_lib/model.py index 37241aa6e..75161b7a2 100644 --- a/plugins/ndf/scripts/project_lib/model.py +++ b/plugins/ndf/scripts/project_lib/model.py @@ -47,7 +47,12 @@ class Container(schema.Shape): class Suite(schema.Shape): """テストの 1 まとまり。`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)に、シェルの引用で守った範囲の - パスが入る。`command` と `scope_command` はシェルで走らせる。 + パスの並びが入る。入るパスの種類は場所で違う: cross-refactoring の改修の範囲テストではテストのファイルのパス + (計画の `test_targets` の `::` より前)、全体テストを CI に任せる戦略の着手前のテストでは `--scope` のテストの置き場所 + (ディレクトリかファイル)、静的解析(`kind: lint`)では変更したファイルのパス。テストの `scope_command` はテストの + ファイルのパスを受けるコマンドで書く(unittest なら `discover -s {paths}` ではなく `python3 -m unittest {paths}`)。 + ファイルを受けない `scope_command` は、cross-refactoring の `init` がテストのファイルを入れた試走で見分け、改修の前に + 止める。`command` と `scope_command` はシェルで走らせる。 `junit` はコマンドが JUnit XML を書く作業ディレクトリからの相対パス(無ければ落ちたテストの見分けは走らせ直しへ落ちる)。 `kind` は種別で、`test`(テスト)か `lint`(静的解析。整形の検査を含む)。書かなければ `test`。静的解析の suite の範囲は 変更したファイルのうち `paths` に当たるもの。`paths` の要素は接頭辞(ディレクトリ)か glob(`*.sh` など)で、空ならすべて。 diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index 025e1862b..bab60f56f 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -255,7 +255,7 @@ bash "$LIB/bg-wait.sh" wait "$RC" # 1 回 540 秒以内。124 = まだ終わ | --- | --- | --- | | 0 | finalize まで終わった | `items[0].report`(`refactor.py report` の出力)と `metrics` を「完了報告」へ写す | | 23(`cross-review`) | 単独起動の最終ゲート | `prompt_file` を読み、`items[0].cwd`(対象のリポジトリの根)で `items[0].command`(cross-review の駆動)を回す。最終ステータスは cross-review の駆動が `result_file` へ書き、自分では書かない。同じコマンドを打ち直し、結果 JSON の `metrics.review_status` が `approved` のときだけ Draft を解除する。それ以外は Draft のまま最終ステータスを報告して止まる | -| 1 | 中断(`metrics.exit` に元の終了コード。4 = 対象の Pull Request が閉じている・マージ済み・状態を判定できない(作業ディレクトリを作らず、担当の CLI を起動しない)・予算の指定の誤り・着手前のテストが長く手順の枠が想定最大時間に収まらない(要る `--budget-minutes` の下限を出す。下限以上で `init` を打ち直すと着手前のテストを走らせずに続く)・使える時間が 0 分以下で候補がすべて時間で見送られた(計画を置かず、打ち直しで止まっていた時間を除いて作り直す。#1944)・着手前のテストの打ち切り・`--round-test` が要る・旧い状態ファイル・取り消しの失敗(項目に属さないコミットの積み直しか公開済みの revert が衝突した)・打ち切りの後の取り消しで起点へ戻した後でも最終ゲートが落ちた・push の直前に残すコミットでないもの(見放した担当の残留コミットなど)がある・範囲を確定できない など。最終ゲートを経ずに終わった実行もここで、`metrics.adopted` は 0・`metrics.unconfirmed` に残った項目の数) | `summary` を報告して止まる。**握り潰さない**(検証を通っていない変更が残る) | +| 1 | 中断(`metrics.exit` に元の終了コード。4 = 対象の Pull Request が閉じている・マージ済み・状態を判定できない(作業ディレクトリを作らず、担当の CLI を起動しない)・予算の指定の誤り・着手前のテストが長く手順の枠が想定最大時間に収まらない(要る `--budget-minutes` の下限を出す。下限以上で `init` を打ち直すと着手前のテストを走らせずに続く)・使える時間が 0 分以下で候補がすべて時間で見送られた(計画を置かず、打ち直しで止まっていた時間を除いて作り直す。#1944)・着手前のテストの打ち切り・範囲テストのテンプレートが `{paths}` に入るテストのファイルを受けない(着手前のテストの後に試走で見分けた。受けるかを確かめられないときも止め、直す箇所と記録 `template-check.json` を出す)・`--round-test` が要る・旧い状態ファイル・取り消しの失敗(項目に属さないコミットの積み直しか公開済みの revert が衝突した)・打ち切りの後の取り消しで起点へ戻した後でも最終ゲートが落ちた・push の直前に残すコミットでないもの(見放した担当の残留コミットなど)がある・範囲を確定できない など。最終ゲートを経ずに終わった実行もここで、`metrics.adopted` は 0・`metrics.unconfirmed` に残った項目の数) | `summary` を報告して止まる。**握り潰さない**(検証を通っていない変更が残る) | 再開は同じコマンドを打ち直すだけである。進みは耐久の記録(既定の置き場は `~/.local/state/ndf/dbos/refactor-<鍵>.sqlite` )が持ち、 記録のある手順は流し直さない。`metrics` は状態ファイルから数えた件数(`items` / `adopted` / `reverted` / diff --git a/plugins/ndf/skills/development-workflow/references/project-analysis.md b/plugins/ndf/skills/development-workflow/references/project-analysis.md index 172210b08..471d99454 100644 --- a/plugins/ndf/skills/development-workflow/references/project-analysis.md +++ b/plugins/ndf/skills/development-workflow/references/project-analysis.md @@ -68,7 +68,7 @@ CI の壁時計(`ci.workflows[].wall_seconds`)とテストの所要の `ci-s | 項目 | 答えの `value` の形 | 決め方 | | --- | --- | --- | -| `test`(P2) | `{"strategy"?, "ci"?: {"check"?, "junit_artifacts"?}, "suites": [{"name", "runner", "command", "scope_command"?, "junit"?, "container"?: {"service", "compose_files"}, "needs", "paths", "kind"?}]}` | 指示書・README の走らせ方の行を正とする。コンテナ越しなら `container` を書き、`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)にシェルの引用で守った範囲のパスが入る。`command` と `scope_command` はシェルで走らせる。`kind` は suite の種別で、`test`(既定)か `lint`(静的解析。整形の検査を含む)。静的解析の suite は変更したファイルのうち `paths` に当たるものにかかり、`paths` の要素には接頭辞のほかに glob(`*.sh` など)を書ける。`junit` はコマンドが JUnit XML を書く相対パス(pytest は `-o junit_family=xunit1 --junitxml=<パス>` をコマンドに書く。無ければ落ちたテストの見分けが全体の走らせ直しへ落ちる)。`strategy` は `local-full`(全体テストを手元で走らせる)/ `local-scoped-ci-whole`(範囲は手元、全体は CI に任せる。`ci.check` に待つチェック、`ci.junit_artifacts` に JUnit の成果物の名前の glob)/ `round-only`(パスでテストを選べない。suite の `command` をそのまま走らせる)。空なら所要と suite から導く(600 秒を超え CI が読めれば `local-scoped-ci-whole`。`scope_command` が無ければ `round-only`。ほかは `local-full`)ので、その既定を答えとして示し、手元で全体テストを回さない方針があるときだけ書き換える | +| `test`(P2) | `{"strategy"?, "ci"?: {"check"?, "junit_artifacts"?}, "suites": [{"name", "runner", "command", "scope_command"?, "junit"?, "container"?: {"service", "compose_files"}, "needs", "paths", "kind"?}]}` | 指示書・README の走らせ方の行を正とする。コンテナ越しなら `container` を書き、`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)にシェルの引用で守った範囲のパスの並びが入る。入るパスの種類は場所で違い、cross-refactoring の改修の範囲テストではテストのファイルのパス(計画の `test_targets` の `::` より前)、全体テストを CI に任せる戦略の着手前のテストでは `--scope` のテストの置き場所(ディレクトリかファイル)、静的解析の suite では変更したファイルのパスが入る。テストの `scope_command` はテストのファイルのパスを受けるコマンドで書く。ディレクトリしか受けないランナーは、範囲テストをファイルで受ける形と全体テストをディレクトリで走らせる形に分ける(unittest なら `scope_command` を `python3 -m unittest {paths}`、`command` を `python3 -m unittest discover -s <テストのディレクトリ>` にする。`python3 -m unittest tests/test_x.py tests/test_y.py` と `discover -s tests` はどちらも終了コード 0、`discover -s tests/test_x.py` は 1、テストの無いディレクトリを渡した `discover -s scripts/storylib` は 5 で終わる)。ファイルを受けない `scope_command` は、cross-refactoring の `init` がテストのファイルを入れた試走で見分けて改修の前に止める。`command` と `scope_command` はシェルで走らせる。`kind` は suite の種別で、`test`(既定)か `lint`(静的解析。整形の検査を含む)。静的解析の suite は変更したファイルのうち `paths` に当たるものにかかり、`paths` の要素には接頭辞のほかに glob(`*.sh` など)を書ける。`junit` はコマンドが JUnit XML を書く相対パス(pytest は `-o junit_family=xunit1 --junitxml=<パス>` をコマンドに書く。無ければ落ちたテストの見分けが全体の走らせ直しへ落ちる)。`strategy` は `local-full`(全体テストを手元で走らせる)/ `local-scoped-ci-whole`(範囲は手元、全体は CI に任せる。`ci.check` に待つチェック、`ci.junit_artifacts` に JUnit の成果物の名前の glob)/ `round-only`(パスでテストを選べない。suite の `command` をそのまま走らせる)。空なら所要と suite から導く(600 秒を超え CI が読めれば `local-scoped-ci-whole`。`scope_command` が無ければ `round-only`。ほかは `local-full`)ので、その既定を答えとして示し、手元で全体テストを回さない方針があるときだけ書き換える | | `branches`(P6) | `{"base": "<起点>", "production": "<本番>"}` | origin の HEAD・直近 100 件のマージ先・最後のコミットの日時を見る。長くコミットの無いブランチ(`develop` など)は採らない | | `delivery`(P7) | `[{"target", "kind": "auto" / "manual", "trigger", "branch"?, "versioned", "production"?}]` | 配布の設定ファイル・ワークフローの `deploy` の行・版数の置き場とタグから決める。`production`(本番系へ届くか。[pace.md](pace.md) の「手動反映の本番系の形」)は解析では書かず、利用者が書く | | `issues`(P8) | `{"primary", "others"}`。名前は `github`・`markdown`・`redmine`・`external` など | 名前だけを書き、URL や識別子を書かない | diff --git a/plugins/ndf/skills/development-workflow/schemas/project.schema.json b/plugins/ndf/skills/development-workflow/schemas/project.schema.json index 5aa74ab62..a59d9ac91 100644 --- a/plugins/ndf/skills/development-workflow/schemas/project.schema.json +++ b/plugins/ndf/skills/development-workflow/schemas/project.schema.json @@ -415,7 +415,7 @@ }, "Suite": { "additionalProperties": false, - "description": "テストの 1 まとまり。`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)に、シェルの引用で守った範囲の\nパスが入る。`command` と `scope_command` はシェルで走らせる。\n`junit` はコマンドが JUnit XML を書く作業ディレクトリからの相対パス(無ければ落ちたテストの見分けは走らせ直しへ落ちる)。\n`kind` は種別で、`test`(テスト)か `lint`(静的解析。整形の検査を含む)。書かなければ `test`。静的解析の suite の範囲は\n変更したファイルのうち `paths` に当たるもの。`paths` の要素は接頭辞(ディレクトリ)か glob(`*.sh` など)で、空ならすべて。\n`ci_jobs` はこの suite が同じ検査を手元で走らせる継続的統合のジョブの識別子で、cross-refactoring の `init` が\n宣言に無いジョブを知らせるときに見る。", + "description": "テストの 1 まとまり。`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)に、シェルの引用で守った範囲の\nパスの並びが入る。入るパスの種類は場所で違う: cross-refactoring の改修の範囲テストではテストのファイルのパス\n(計画の `test_targets` の `::` より前)、全体テストを CI に任せる戦略の着手前のテストでは `--scope` のテストの置き場所\n(ディレクトリかファイル)、静的解析(`kind: lint`)では変更したファイルのパス。テストの `scope_command` はテストの\nファイルのパスを受けるコマンドで書く(unittest なら `discover -s {paths}` ではなく `python3 -m unittest {paths}`)。\nファイルを受けない `scope_command` は、cross-refactoring の `init` がテストのファイルを入れた試走で見分け、改修の前に\n止める。`command` と `scope_command` はシェルで走らせる。\n`junit` はコマンドが JUnit XML を書く作業ディレクトリからの相対パス(無ければ落ちたテストの見分けは走らせ直しへ落ちる)。\n`kind` は種別で、`test`(テスト)か `lint`(静的解析。整形の検査を含む)。書かなければ `test`。静的解析の suite の範囲は\n変更したファイルのうち `paths` に当たるもの。`paths` の要素は接頭辞(ディレクトリ)か glob(`*.sh` など)で、空ならすべて。\n`ci_jobs` はこの suite が同じ検査を手元で走らせる継続的統合のジョブの識別子で、cross-refactoring の `init` が\n宣言に無いジョブを知らせるときに見る。", "properties": { "name": { "title": "Name", From e289596ec0c7a787cbbdda7c9e9d8846a5b3e1ec Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 02:25:19 +0000 Subject: [PATCH 07/35] =?UTF-8?q?cross-review:=20=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=AE=E7=84=A1=E3=81=84=E5=AE=A3=E8=A8=80=E3=81=A7?= =?UTF-8?q?=E6=9C=80=E7=B5=82=E3=82=B9=E3=82=A4=E3=83=BC=E3=83=97=E3=82=92?= =?UTF-8?q?=E6=AD=A2=E3=82=81=E3=81=9A=E3=80=81=E4=BB=B6=E6=95=B0=E3=81=AB?= =?UTF-8?q?=E7=B6=99=E7=B6=9A=E7=9A=84=E7=B5=B1=E5=90=88=E3=81=AE=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=82=92=E8=BC=89=E3=81=9B=E3=82=8B=EF=BC=88#1593?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify_commands は種別・雛形の誤りを先に当て、strategy を書かずどの suite も command と scope_command を持たない宣言({"suites": []} を含む)では None を返す - 最終スイープの指示を組めないときの Stop の理由文に止めた所を名指しする - 駆動の件数へ最後のラウンドの継続的統合の判定 ci と理由 ci_reason を足す Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/test_strategy.py | 8 ++- .../scripts/tests/test_lib_test_strategy.py | 22 +++++++++ plugins/ndf/skills/cross-review/SKILL.md | 4 +- .../ndf/skills/cross-review/scripts/drive.py | 12 ++--- .../cross-review/scripts/review_lib/ci.py | 8 +++ .../tests/test_drive_sweep_verify.py | 49 ++++++++++++++++++- 6 files changed, 93 insertions(+), 10 deletions(-) diff --git a/plugins/ndf/scripts/lib/test_strategy.py b/plugins/ndf/scripts/lib/test_strategy.py index fe8fd9c8a..4755f57df 100644 --- a/plugins/ndf/scripts/lib/test_strategy.py +++ b/plugins/ndf/scripts/lib/test_strategy.py @@ -601,11 +601,17 @@ def verify_commands(decl: dict[str, Any]) -> Optional[list[str]]: """cross-review の最終スイープに名指しする全体テスト(両方の種別。I13)。宣言の `test` が無い・不明なら `None`。 `test` があって解けない(不正な `kind` など)ときは `StrategyError` をそのまま上げる。宣言が無いときと - 分けないと、不正な宣言が最終スイープの既定の探し方へ黙って流れる。 + 分けないと、不正な宣言が最終スイープの既定の探し方へ黙って流れる。種別・雛形の誤りを先に当て、誤りが無く + `strategy` を書かず、どの suite も `command` と `scope_command` を持たない宣言(テストの無い宣言。 + `{"suites": []}` を含む)は名指しするコマンドが無いとして `None` を返す(#1593)。 """ test, _ = _test_of(decl) if test is None: return None + suites = _suites_of(test) + _check_templates(suites) + if not test.get("strategy") and not any(s.command or s.scope_command for s in suites): + return None return resolve(decl).whole_commands() or None diff --git a/plugins/ndf/scripts/tests/test_lib_test_strategy.py b/plugins/ndf/scripts/tests/test_lib_test_strategy.py index cccef261c..7cb1bd537 100644 --- a/plugins/ndf/scripts/tests/test_lib_test_strategy.py +++ b/plugins/ndf/scripts/tests/test_lib_test_strategy.py @@ -482,6 +482,28 @@ def test_verify_commands_stops_on_an_invalid_declaration(): ts.verify_commands(decl) +@pytest.mark.parametrize( + "test", + [{"suites": []}, {"suites": [{"name": "docs", "runner": "none"}]}, {"suites": [{"name": "md", "kind": "lint"}]}], +) +def test_verify_commands_name_nothing_for_a_declaration_without_tests(test): + """#1593 I1 — テストの無い宣言(`{"suites": []}` を含む)は名指しするコマンドが無いとして `None`。""" + assert ts.verify_commands({"test": test}) is None + + +@pytest.mark.parametrize( + "test, key", + [ + ({"suites": [{"name": "py", "kind": "unit"}]}, r"test\.suites\[0\]\.kind"), + ({"strategy": "local-scoped-ci-whole", "suites": []}, "scope_command"), + ], +) +def test_verify_commands_still_stop_on_a_wrong_declaration_without_tests(test, key): + """#1593 I2 — command の無い宣言でも、種別の誤りと解けない戦略は今のとおり止める。""" + with pytest.raises(ts.StrategyError, match=key): + ts.verify_commands({"test": test}) + + # ---------- #1437: 雛形から組んだ全体テストの注記 ---------- PYTEST_DECL = {"test": {"suites": [{"name": "py", "runner": "pytest", "command": "pytest -q"}]}} diff --git a/plugins/ndf/skills/cross-review/SKILL.md b/plugins/ndf/skills/cross-review/SKILL.md index b16ba87db..55dd7c167 100644 --- a/plugins/ndf/skills/cross-review/SKILL.md +++ b/plugins/ndf/skills/cross-review/SKILL.md @@ -167,7 +167,9 @@ JSON の形と終了コードの表は共通ライブラリの `scripts/lib/driv 再開は同じコマンドを打ち直すだけである。進みは耐久の記録(`~/.local/state/ndf/dbos/review-<鍵>.sqlite`)と state.json にあり、pause の 結果ファイルがあればその続きから進む。`metrics` は state.json から数えた件数(`rounds` / `prs` / `findings` / -`fixed` / `deferred` / `rejected` / `unresolved` / `final` / `review_status`)である。 +`fixed` / `deferred` / `rejected` / `unresolved` / `final` / `review_status` / `ci` / `ci_reason`)である。`ci` は最後のラウンドの +継続的統合の判定(`ci.verdict`。無ければ `null`)、`ci_reason` はその判定が `unverified`・`meta_only`・`pending` のときの理由 +(`unverified` の理由・`meta_only` の注記・未完了のチェックジョブの名前)で、ほかは `null` である。 **終わったレビューにもう一度回すときも、同じコマンドを打ち直すだけである。** state.json を開かない・`mv` しない・ 書き換えない。終わりの時点の PR の head と今の head が違えば(差分を足した)、`state.py init` が `final` を外して diff --git a/plugins/ndf/skills/cross-review/scripts/drive.py b/plugins/ndf/skills/cross-review/scripts/drive.py index e54a8d9c5..9e8700dc3 100755 --- a/plugins/ndf/skills/cross-review/scripts/drive.py +++ b/plugins/ndf/skills/cross-review/scripts/drive.py @@ -14,7 +14,7 @@ 止まるときの JSON の形と終了コードの表は `scripts/lib/drive_pause.py` にある。 -件数(metrics)は状態ファイルから数える: rounds / prs / findings / fixed / deferred / rejected / unresolved / final / review_status。 +件数(metrics)は状態ファイルから数える。キーの一覧は SKILL.md の「実行」の節にある。 """ from __future__ import annotations @@ -40,6 +40,7 @@ from drive_pause import Stop # noqa: E402 from loop_drive import call, durable_identity, keep_finished_output, parse_vars # noqa: E402,F401 テストは `call` をこのモジュールの上で差し替える from loop_drive import relaunch_seats, result_args, review_status, write_review_answer # noqa: E402 +from review_lib.ci import ci_counts # noqa: E402 TOOL = "cross-review-drive" PLACE_ARGS = frozenset({"--worktree"}) # 置き場だけを変える init の引数。完了の記録の比較から除く(#1263 の I4) @@ -57,10 +58,8 @@ def review_paths(): def sweep_verify_lines(worktree) -> str: """最終スイープの検証のコマンドの段落(#1483 I13)。宣言の `test` があれば全体テスト(両方の種別)を名指しする。 - - 宣言が無い・読めなければ空で、プロンプトは今の探し方(Step 7.5)のまま。`test` があって解けない(不正な - `kind` など)ときは、どのキーが不正かを添えて `Stop` で止める。`--verify-command` は読まない。 - """ + 宣言が無い・読めない・テストの無い宣言(#1593)なら空で、プロンプトは今の探し方(Step 7.5)のまま。`test` があって + 解けない(不正な `kind` など)ときは、どのキーが不正かを添えて `Stop` で止める。`--verify-command` は読まない。""" if not worktree: return "" import project_decl @@ -73,7 +72,7 @@ def sweep_verify_lines(worktree) -> str: try: commands = ts.verify_commands(decl) except ts.StrategyError as e: - raise Stop(f".ndf/project.json の test が不正: {e}", 2) from e + raise Stop(f"最終スイープの指示を組めない: .ndf/project.json の test が不正: {e}", 2) from e if not commands: return "" listed = "\n".join(f" - {c}" for c in commands) @@ -164,6 +163,7 @@ def counts(self) -> dict: "unresolved": sweep.get("remaining_open"), "final": s.get("final"), "review_status": review_status(s), + **ci_counts(rounds), } # --- 止まる --- diff --git a/plugins/ndf/skills/cross-review/scripts/review_lib/ci.py b/plugins/ndf/skills/cross-review/scripts/review_lib/ci.py index 3e6eb9cd4..006247c58 100644 --- a/plugins/ndf/skills/cross-review/scripts/review_lib/ci.py +++ b/plugins/ndf/skills/cross-review/scripts/review_lib/ci.py @@ -141,3 +141,11 @@ def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, An if c.pending: return {"verdict": "pending", "sha": sha, "pending": c.pending} return {"verdict": "success", "sha": sha} + + +def ci_counts(rounds: list) -> dict: + """最後のラウンドの継続的統合の判定(`ci`)と理由(`ci_reason`)。判定が無ければ両方 `None`(#1593 I3)。""" + ci = (rounds[-1].get("ci") if rounds else None) or {} + verdict = ci.get("verdict") + reason = {"unverified": ci.get("reason"), "meta_only": ci.get("note"), "pending": " ".join(ci.get("pending") or []) or None} + return {"ci": verdict, "ci_reason": reason.get(verdict)} diff --git a/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py b/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py index 8ee48433b..69a9d657f 100644 --- a/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py +++ b/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py @@ -1,4 +1,5 @@ -"""drive.py の最終スイープの検証コマンド: 不正な `test` 宣言は既定の探し方へ流さず止める(#1483 I13)。""" +"""drive.py の最終スイープの検証コマンド: 不正な `test` 宣言は既定の探し方へ流さず止める(#1483 I13)。 +テストの無い宣言は止めずに今の探し方へ進み、件数に最後のラウンドの継続的統合の判定が載る(#1593)。""" from __future__ import annotations @@ -43,6 +44,50 @@ def test_a_valid_declaration_names_the_commands(tmp_path): def test_an_invalid_declaration_stops_with_the_key(tmp_path): root = _decl(tmp_path, {"test": {"suites": [{"name": "py", "kind": "bogus", "command": "pytest ."}]}}) - with pytest.raises(cr.Stop, match=r"test\.suites\[0\]\.kind") as e: + with pytest.raises(cr.Stop, match=r"^最終スイープの指示を組めない: .*test\.suites\[0\]\.kind") as e: cr.sweep_verify_lines(root) assert e.value.code == 2 + + +def test_a_declaration_without_tests_keeps_the_default_search(tmp_path): + """#1593 I1 — 解析が「テストが無い」と測った宣言では止めず、今の探し方の段落(空)を返す。""" + assert cr.sweep_verify_lines(_decl(tmp_path, {"test": {"suites": []}})) == "" + + +def test_a_wrong_kind_without_command_still_stops(tmp_path): + """#1593 I2 — command の無い suite でも、種別の誤りは終了コード 2 で止める。""" + with pytest.raises(cr.Stop, match=r"test\.suites\[0\]\.kind") as e: + cr.sweep_verify_lines(_decl(tmp_path, {"test": {"suites": [{"name": "py", "kind": "unit"}]}})) + assert e.value.code == 2 + + +UNVERIFIED = {"verdict": "unverified", "reason": "チェックジョブを照会できない(未 push・権限・チェックジョブ 0 件のいずれか)"} + + +@pytest.mark.parametrize( + "ci, expected", + [ + (UNVERIFIED, ("unverified", UNVERIFIED["reason"])), + ({"verdict": "meta_only", "note": "メタチェックのみ失敗", "pending": []}, ("meta_only", "メタチェックのみ失敗")), + ({"verdict": "pending", "pending": ["build", "lint"]}, ("pending", "build lint")), + ({"verdict": "success", "sha": "abc"}, ("success", None)), + (None, (None, None)), + ], +) +def test_counts_carry_the_last_round_ci(ci, expected): + """#1593 I3 — 件数の `ci`・`ci_reason` は最後のラウンドの判定から写す(最初のラウンドは読まない)。""" + rounds = [{"ci": {"verdict": "code_failure", "failed": ["x"]}}, {"ci": ci} if ci else {}] + got = cr.ci_counts(rounds) + assert (got["ci"], got["ci_reason"]) == expected + assert cr.ci_counts([]) == {"ci": None, "ci_reason": None} + + +def test_counts_keep_the_existing_keys(monkeypatch): + """#1593 I4 — 足すのは `ci`・`ci_reason` だけで、`review_status` ほかの値は変えない。""" + state = {"final": "approved", "sweep": None, "rounds": [{"fix": {"fixed": 1}, "ci": UNVERIFIED}]} + d = cr.Drive(1, "light", []) + monkeypatch.setattr(d, "state", lambda: state) + counts = d.counts() + assert counts["review_status"] == cr.review_status(state) == "unverified" + assert (counts["final"], counts["fixed"], counts["rounds"], counts["ci"]) == ("approved", 1, 1, "unverified") + assert counts["ci_reason"] == UNVERIFIED["reason"] From 58809cf9a9f3048bd2de419d3f545693a39c3646 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 02:25:19 +0000 Subject: [PATCH 08/35] =?UTF-8?q?supervise:=20=E9=A7=86=E5=8B=95=E3=81=AE?= =?UTF-8?q?=20stopped=20=E3=81=A7=E6=AD=A2=E3=81=BE=E3=81=A3=E3=81=9F?= =?UTF-8?q?=E7=90=86=E7=94=B1=E3=81=AB=E5=85=83=E3=81=AE=E7=B5=82=E4=BA=86?= =?UTF-8?q?=E3=82=B3=E3=83=BC=E3=83=89=E3=82=92=E6=B7=BB=E3=81=88=E3=82=8B?= =?UTF-8?q?=EF=BC=88#1593=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 結果 JSON が stopped で metrics.exit が整数のとき、止まった理由の要約の後ろへ 「(元の終了コード N)」を添える。abort へ回したときの直前のステップの要約にも当てる。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/engine.py | 17 +++++- plugins/ndf/scripts/supervise_lib/plan.py | 6 +- .../scripts/tests/test_supervise_stop_exit.py | 57 +++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 plugins/ndf/scripts/tests/test_supervise_stop_exit.py diff --git a/plugins/ndf/scripts/supervise_lib/engine.py b/plugins/ndf/scripts/supervise_lib/engine.py index 0a4726f50..b0625872d 100644 --- a/plugins/ndf/scripts/supervise_lib/engine.py +++ b/plugins/ndf/scripts/supervise_lib/engine.py @@ -30,6 +30,16 @@ from supervise_lib.worker_steps import DriveStep, WorkStep +def _result_summary(text: str) -> str: + """ステップの出力の最後の JSON の summary。`status` が `stopped` で `metrics.exit` が整数なら「(元の終了コード N)」を添える。""" + res = last_json(text) or {} + summary = str(res.get("summary") or "") + code = (res.get("metrics") or {}).get("exit") if isinstance(res.get("metrics"), dict) else None + if summary and res.get("status") == "stopped" and isinstance(code, int) and not isinstance(code, bool): + return f"{summary}(元の終了コード {code})" + return summary + + class PolicyClaudeRunner(ClaudeRunner): """ランタイムの宣言(#1598)を起動の前に照らす `ClaudeRunner`。 @@ -335,12 +345,13 @@ def _next_after_step(self, sid: str, step: dict, ok: bool) -> tuple[str | None, def _stop_detail(self, sid: str) -> str: """止まった理由へ添える要約: このステップの結果の summary と、直前のステップが on_fail でここへ回した失敗の - summary(検査のプランが abort へ回した理由を読めるように。#1917 の決定 7)。""" + summary(検査のプランが abort へ回した理由を読めるように。#1917 の決定 7)。結果が駆動の stopped で + `metrics.exit`(止めたスクリプトの元の終了コード。`lib/drive_pause.py`)を持てば、要約の後ろへ添える(#1593)。""" st = self.state - parts = [(last_json(st.cur.get("text", "")) or {}).get("summary") or ""] + parts = [_result_summary(st.cur.get("text", ""))] prev = st.log[-1].get("id") if st.log else None if prev and prev == st.failed_step and prev != sid and self.steps.get(prev, {}).get("on_fail") == sid: - why = (last_json((st.results.get(prev) or {}).get("text", "")) or {}).get("summary") or "" + why = _result_summary((st.results.get(prev) or {}).get("text", "")) parts.append(f"{prev}: {why}" if why else "") return "".join(f": {p}" for p in parts if p) diff --git a/plugins/ndf/scripts/supervise_lib/plan.py b/plugins/ndf/scripts/supervise_lib/plan.py index 1634ae5b5..8554b2281 100644 --- a/plugins/ndf/scripts/supervise_lib/plan.py +++ b/plugins/ndf/scripts/supervise_lib/plan.py @@ -50,11 +50,13 @@ `external-ai.py run` で起動する(起動・上限つきの待ち・回収はそのコマンドが持つ)。書かなければ最小構成の claude -p。 drive のステップ: `cmd`(または `"drive": "cross-review" | "cross-refactoring"` と `"args"`)を打ち、最後の行の JSON を読む。 -- `status` が `ok` なら成功。`metrics`(ラウンド数・指摘・未解決・適用・取り消しなど)を報告の「件数」へ載せる +- `status` が `ok` なら成功。`metrics`(ラウンド数・指摘・未解決・適用・取り消し・cross-review の継続的統合の判定 `ci` + と理由 `ci_reason` など)を報告の「件数」へ載せる - `gate` なら `items[0]` の `prompt_file` を worker に渡し、`result_file` を書かせてから同じコマンドを打ち直す。 `command` を持つ pause(最終ゲートの cross-review)は、その駆動を同じ形で回し、`metrics.review_status` を `result_file` へ書く -- `stopped`・結果ファイルが書かれない・`"max_pauses"`(既定 12)を超える、のどれかなら失敗 +- `stopped`・結果ファイルが書かれない・`"max_pauses"`(既定 12)を超える、のどれかなら失敗。`stopped` の止まった理由には + 駆動の `summary` と、`metrics.exit` があれば「(元の終了コード N)」が添う ステップごとの作業場所: run と work のステップに `"cwd"` を書くと、そのステップだけ別の場所で動く(取り込みで PR ごとに 作業ツリーが違うとき)。work のステップでは worker へ渡す「作業場所」もその `cwd` になる。 diff --git a/plugins/ndf/scripts/tests/test_supervise_stop_exit.py b/plugins/ndf/scripts/tests/test_supervise_stop_exit.py new file mode 100644 index 000000000..3f3a2ed89 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_supervise_stop_exit.py @@ -0,0 +1,57 @@ +"""drive のステップが駆動の stopped で失敗したとき、止まった理由に駆動の summary と元の終了コードが載る(#1593 I5)。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +sys.path.insert(0, str(SCRIPTS / "lib")) +from supervise_lib import engine # noqa: E402 +from test_supervise_limits import reason_of # noqa: E402 + +PY = sys.executable +WHY = "review: 最終スイープの指示を組めない: .ndf/project.json の test が不正: test.suites[0].kind は test か lint で書く(今: unit)" + + +def said(status, metrics, code): + obj = {"tool": "t", "status": status, "summary": WHY, "items": [], "metrics": metrics} + return f"{PY} -c 'print({json.dumps(json.dumps(obj, ensure_ascii=False))}); raise SystemExit({code})'" + + +def run(tmp_path, steps): + return engine.Engine({"フェーズ": "検査", "作業場所": str(tmp_path), "steps": steps}, tmp_path / "state").run(None) + + +@pytest.mark.parametrize("kind", ["drive", "run"]) +def test_the_stop_reason_carries_the_original_exit(tmp_path, kind): + text = run(tmp_path, [{"id": "review", "type": kind, "cmd": said("stopped", {"exit": 2}, 1), "next": "end"}]) + reason = reason_of(text) + assert "ステップ review が失敗した" in reason and f"{WHY}(元の終了コード 2)" in reason, text + + +def test_the_abort_route_carries_the_original_exit_of_the_review(tmp_path): + review = {"id": "review", "type": "drive", "cmd": said("stopped", {"exit": 2}, 1), "next": "end", "on_fail": "abort"} + abort = {"id": "abort", "type": "run", "cmd": "false", "next": "end"} + reason = reason_of(run(tmp_path, [review, abort])) + assert "ステップ abort が失敗した" in reason and f"review: {WHY}(元の終了コード 2)" in reason, reason + + +@pytest.mark.parametrize( + "status, metrics", + [("stopped", {}), ("stopped", {"exit": "2"}), ("ok", {"exit": 2})], +) +def test_no_exit_is_added_without_a_stopped_integer_exit(tmp_path, status, metrics): + reason = reason_of(run(tmp_path, [{"id": "review", "type": "run", "cmd": said(status, metrics, 1), "next": "end"}])) + assert WHY in reason and "元の終了コード" not in reason, reason + + +def test_the_counts_line_carries_the_unverified_ci(tmp_path): + """#1593 I3・受け入れ条件 3 — 駆動の件数の `ci`・`ci_reason` が報告の件数の行に載り、`null` のキーは出ない。""" + metrics = {"rounds": 3, "review_status": "approved", "ci": "unverified", "ci_reason": "チェックジョブ 0 件", "unresolved": None} + text = run(tmp_path, [{"id": "review", "type": "drive", "cmd": said("ok", metrics, 0), "next": "end"}]) + assert "ci unverified / ci_reason チェックジョブ 0 件" in text and "unresolved" not in text, text From 5116d6c60d9b30ea8fb0e51d63346d6259580aa7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 02:47:13 +0000 Subject: [PATCH 09/35] =?UTF-8?q?cross-review:=20=E9=A7=86=E5=8B=95?= =?UTF-8?q?=E3=81=AE=E4=BB=B6=E6=95=B0=E3=81=AE=20ci=5Fcounts=20=E3=82=92?= =?UTF-8?q?=20counts=20=E3=81=AE=E4=B8=AD=E3=81=A7=E8=AA=AD=E3=82=80?= =?UTF-8?q?=EF=BC=88#1593=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drive.py の先頭で review_lib を読むと、scripts/ を sys.path に持たない呼び出し元 (supervise のテストなど)で読み込みが失敗する。review_paths と同じく counts の中で読む。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/skills/cross-review/scripts/drive.py | 8 ++++---- .../skills/cross-review/tests/test_drive_sweep_verify.py | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/drive.py b/plugins/ndf/skills/cross-review/scripts/drive.py index 9e8700dc3..ae4b1b969 100755 --- a/plugins/ndf/skills/cross-review/scripts/drive.py +++ b/plugins/ndf/skills/cross-review/scripts/drive.py @@ -12,9 +12,7 @@ 立てて続きを待ち、打ち直しが続き(`resume`)を送る。結果ファイルが無ければ同じ止まりを番号を増やして返す。 途中のスクリプトが失敗したときも stopped を返して同じように待ち、打ち直しで同じ段階をやり直す。 -止まるときの JSON の形と終了コードの表は `scripts/lib/drive_pause.py` にある。 - -件数(metrics)は状態ファイルから数える。キーの一覧は SKILL.md の「実行」の節にある。 +止まるときの JSON の形と終了コードの表は `scripts/lib/drive_pause.py`、件数(metrics)のキーの一覧は SKILL.md の「実行」の節にある。 """ from __future__ import annotations @@ -40,7 +38,6 @@ from drive_pause import Stop # noqa: E402 from loop_drive import call, durable_identity, keep_finished_output, parse_vars # noqa: E402,F401 テストは `call` をこのモジュールの上で差し替える from loop_drive import relaunch_seats, result_args, review_status, write_review_answer # noqa: E402 -from review_lib.ci import ci_counts # noqa: E402 TOOL = "cross-review-drive" PLACE_ARGS = frozenset({"--worktree"}) # 置き場だけを変える init の引数。完了の記録の比較から除く(#1263 の I4) @@ -141,6 +138,9 @@ def counts(self) -> dict: `findings` は修正担当が扱った指摘(各ラウンドの直した・見送った・却下したの和)で、`findings >= fixed` が 数え方で成り立つ。レビュー担当が出したコメントの数は `comments` で別に出す(1 つのコメントに複数の指摘が 入り、2 者が同じ所を指せば 2 件になるため、指摘の数と比べない)。収束の判定はこの値を読まない。""" + review_paths() # 継続的統合の判定の写し(ci_counts)は review_lib にある + from review_lib.ci import ci_counts + s = self.state() rounds = s.get("rounds") or [] comments = fixed = deferred = rejected = 0 diff --git a/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py b/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py index 69a9d657f..dd4197a8d 100644 --- a/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py +++ b/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py @@ -25,6 +25,7 @@ def _load(): cr = _load() +from review_lib.ci import ci_counts # noqa: E402 conftest が scripts を読めるようにする def _decl(root: Path, decl: dict) -> Path: @@ -77,9 +78,9 @@ def test_a_wrong_kind_without_command_still_stops(tmp_path): def test_counts_carry_the_last_round_ci(ci, expected): """#1593 I3 — 件数の `ci`・`ci_reason` は最後のラウンドの判定から写す(最初のラウンドは読まない)。""" rounds = [{"ci": {"verdict": "code_failure", "failed": ["x"]}}, {"ci": ci} if ci else {}] - got = cr.ci_counts(rounds) + got = ci_counts(rounds) assert (got["ci"], got["ci_reason"]) == expected - assert cr.ci_counts([]) == {"ci": None, "ci_reason": None} + assert ci_counts([]) == {"ci": None, "ci_reason": None} def test_counts_keep_the_existing_keys(monkeypatch): From 8cbf86aeff7472c0879eb4e9a840023fd343fe41 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 02:54:08 +0000 Subject: [PATCH 10/35] =?UTF-8?q?refactor(cross-refactoring):=20template?= =?UTF-8?q?=5Fcheck=20=E3=81=AE=E9=96=A2=E6=95=B0=E5=90=8D=E3=82=92?= =?UTF-8?q?=E4=BB=96=E3=81=A8=E9=87=8D=E3=81=AA=E3=82=89=E3=81=AA=E3=81=84?= =?UTF-8?q?=E5=90=8D=E3=81=B8=E5=A4=89=E3=81=88=E3=82=8B=EF=BC=88#1630?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-script-structure の same-name 違反(_candidates / run / summary)を解く。 Co-Authored-By: Claude Opus 5.5 --- .../scripts/refactor_lib/template_check.py | 12 ++++++------ .../ndf/skills/cross-refactoring/tests/test_init.py | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py index 21db325ce..96a2516f1 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py @@ -68,7 +68,7 @@ def _fix_at(strategy: ts.Strategy, decl: dict[str, Any], suite: ts.Suite, flag: return index, f".ndf/project.json の test.suites[{index}].scope_command" -def _candidates(strategy: ts.Strategy, suite: ts.Suite, scope: list[str], work: pathlib.Path) -> tuple[list[str], list[str], list[str]]: +def _probe_candidates(strategy: ts.Strategy, suite: ts.Suite, scope: list[str], work: pathlib.Path) -> tuple[list[str], list[str], list[str]]: """`--scope` のテストの置き場所の配下・リポジトリ全体・suite の `paths` の配下の候補(選ぶのは `probe_paths`)。""" w = str(work) in_scope = [f for loc in test_locations(scope, w) for f in test_files_under(loc, w)] @@ -96,7 +96,7 @@ def _check_one( baseline, work = ctx["baseline"], ctx["work"] failures = baseline.get("existing_failures") failing = list(test_triage.by_file(list(failures or []))) - pick = ts.probe_paths(strategy, suite, *_candidates(strategy, suite, ctx["scope"], work), failing) + pick = ts.probe_paths(strategy, suite, *_probe_candidates(strategy, suite, ctx["scope"], work), failing) index, fix_at = _fix_at(strategy, ctx["decl"], suite, ctx["flag"]) reference = _reference(baseline, suite, pick.source) command = ts.fill(str(suite.scope_command), pick.paths) if pick.paths else None @@ -154,7 +154,7 @@ def stop_text(row: dict[str, Any]) -> str: return "\n".join(lines) -def run( +def check_templates( strategy: ts.Strategy, baseline: dict[str, Any], scope: list[str], @@ -188,7 +188,7 @@ def run( return Report(stopped, rows, message, path) -def summary(report: Report) -> str: +def report_summary(report: Report) -> str: """続けるときの 1 行。""" shown = "、".join(f"{r['suite']}({' '.join(r['paths'])})" for r in report.suites) return f"✅ 範囲テストのテンプレートを試走: {shown}" @@ -199,10 +199,10 @@ def enforce(args: Any, prep: Any, baseline: dict[str, Any], timeout: int) -> lis 続けるときは状態ファイルの `template_checks` に置く記録を返す。""" round_test = getattr(args, "round_test", None) flag = "--round-test" if ts.has_paths(round_test) else "--baseline-test" - report = run(prep.strategy, baseline, list(args.scope), prep.work, prep.tmp_dir, timeout, decl=prep.decl, flag=flag) + report = check_templates(prep.strategy, baseline, list(args.scope), prep.work, prep.tmp_dir, timeout, decl=prep.decl, flag=flag) if report.stopped: info(report.message) die(f"範囲テストのテンプレートを直してから打ち直してください(記録: {report.path})") if report.suites: - info(summary(report)) + info(report_summary(report)) return report.suites diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index d3b72d4e2..dcf286e37 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -169,7 +169,7 @@ def fake_probe(runtimes, *, info, env=None, models=None, level="model"): def _no_template_check(monkeypatch): template_check = sys.modules["refactor_lib.template_check"] - monkeypatch.setattr(template_check, "run", lambda *a, **k: template_check.Report(False)) + monkeypatch.setattr(template_check, "check_templates", lambda *a, **k: template_check.Report(False)) def refactor_abort(): From ef582e6127bc9106c9c3cdbd3e8c2cf9dbad157c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 02:54:20 +0000 Subject: [PATCH 11/35] =?UTF-8?q?Update:=20=E5=AE=A3=E8=A8=80=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E5=BD=A2=E3=82=92=E5=BD=93=E3=81=A6=E3=82=8B=EF=BC=88?= =?UTF-8?q?lint-fix=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cross-refactoring/scripts/refactor_lib/template_check.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py index 96a2516f1..e09289aa8 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py @@ -68,7 +68,9 @@ def _fix_at(strategy: ts.Strategy, decl: dict[str, Any], suite: ts.Suite, flag: return index, f".ndf/project.json の test.suites[{index}].scope_command" -def _probe_candidates(strategy: ts.Strategy, suite: ts.Suite, scope: list[str], work: pathlib.Path) -> tuple[list[str], list[str], list[str]]: +def _probe_candidates( + strategy: ts.Strategy, suite: ts.Suite, scope: list[str], work: pathlib.Path +) -> tuple[list[str], list[str], list[str]]: """`--scope` のテストの置き場所の配下・リポジトリ全体・suite の `paths` の配下の候補(選ぶのは `probe_paths`)。""" w = str(work) in_scope = [f for loc in test_locations(scope, w) for f in test_files_under(loc, w)] From 896bf2e31f98fe92e2e041c0b7f2c729763807cb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 02:55:35 +0000 Subject: [PATCH 12/35] =?UTF-8?q?fix(ndf):=20merged-steps.py=20=E3=82=92?= =?UTF-8?q?=20500=20=E8=A1=8C=E3=81=AB=E5=8F=8E=E3=82=81=E3=82=8B=EF=BC=88?= =?UTF-8?q?#1529=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit promote の --expect-head-from の説明を詰め、script-structure の行数の上限を超えないようにする。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/merged-steps.py | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/plugins/ndf/scripts/merged-steps.py b/plugins/ndf/scripts/merged-steps.py index 5e958f5b7..4ca87bdb6 100755 --- a/plugins/ndf/scripts/merged-steps.py +++ b/plugins/ndf/scripts/merged-steps.py @@ -6,8 +6,7 @@ python3 merged-steps.py merge-gate (--base <宛先> | --pr ) [--pr ] [--root ] python3 merged-steps.py merge-when-green [--gate-approved user|mvv] [--method merge|squash|rebase] [--interval 秒] [--timeout 秒] [--stale-after 秒] [--no-cleanup] [--expect-head ] [--root ] - python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--gate-approved user|mvv] - [--expect-head-from <導入の確認の承認資料>] + python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--gate-approved user|mvv] [--expect-head-from <資料>] cleanup: マージ済みの PR の作業ツリーとローカルブランチを外し、主ディレクトリを取り込む。 `git worktree remove` が拒否した作業ツリーは、未追跡・無視されたファイルを退避してから外す。作り直せる生成物 @@ -20,16 +19,11 @@ 最初の読みで宛先(baseRefName)を判定し、自動反映の本番チャネルか判定できない宛先なら、--gate-approved が無い限り CI を待たずに承認ゲート 2 で止まる(status: gate・metrics.gate: production-merge。判定は lib/delivery.py。#1336)。 merge-gate: 宛先の判定だけを行う(0 = 進めてよい / 10 = 承認ゲート 2)。 -promote: 昇格の Pull Request(ベースブランチ → 本番チャネル)を探すか作り、merge-when-green と同じ判定と待ちで -マージする(後片付けはしない)。--prepare は用意して承認資料を書くところで 0 で終える。--expect-head-from に -導入の確認の承認資料(`release-steps.py verify-facts` が書く)を渡すと、そこに書かれた確かめたコミットを --expect-head と -同じ照合に使い、昇格の Pull Request の先端がそのコミットのときだけマージする(違えば items[].result: head_moved で 1。 -資料が無い・確かめたコミットを読めなければ gh を呼ばずに 2)。fast / auto の昇格のプランはこれで止まると handoff で -承認ゲート 2 へ戻る(#1529)。 -実行が終わったのにチェックが pending のまま --stale-after 秒続けば、そのジョブを 1 度だけ -`gh run rerun --job` で再実行し、再実行でも取り残されれば止まる。実行が終わりジョブに結論があれば、チェックの表示が -pending のままでも待たずにその結論で扱う。ジョブがランナーを待つ間は、待ち行列の件数を待ちの 1 周ごとに stderr へ 1 行出す。 -同じチェック(workflow の名前と名前の組)は最新の項目だけで数え、古い項目の失敗は items に result: superseded で残す。 +promote: 昇格の Pull Request(ベースブランチ → 本番チャネル)を探すか作り、merge-when-green と同じ判定と待ちでマージする(後片付けはしない)。 +--prepare は用意して承認資料を書くところで 0 で終える。--expect-head-from は導入の確認の承認資料の確かめたコミットを --expect-head と同じ照合に使う(違えば head_moved で 1、読めなければ 2。#1529)。 +実行が終わったのにチェックが pending のまま --stale-after 秒続けば、そのジョブを 1 度だけ `gh run rerun --job` で再実行し、 +再実行でも取り残されれば止まる。実行が終わりジョブに結論があれば、チェックの表示が pending のままでも待たずにその結論で扱う。 +ジョブがランナーを待つ間は、待ち行列の件数を待ちの 1 周ごとに stderr へ 1 行出す。同じチェック(workflow の名前と名前の組)は最新の項目だけで数え、古い項目の失敗は items に result: superseded で残す。 Runner が付かずに取り消されたジョブ(ステップ 0 件・Runner の名前が空)は CI の基盤待ちとし、実行が終わって --infra-gap 秒の後に `gh run rerun --failed` で打ち直して待ち直す(--infra-reruns 回まで)。使い切るか待ちの上限に 達すると終了コード 75 で止まる(items[].result: infra_wait)。中身の失敗が同時にあれば中身の失敗で止まる。 @@ -484,12 +478,7 @@ def build_parser(): pm.add_argument("--base", required=True, help="本番チャネル") pm.add_argument("--prepare", action="store_true", help="Pull Request を用意して承認資料を書くところで終える(マージしない)") pm.add_argument("--out", help="--prepare の承認資料の置き場(既定は提示物の置き場)") - pm.add_argument( - "--expect-head-from", - metavar="承認資料", - help="導入の確認の承認資料(verify-facts が書く)。確かめたコミットが PR の先端のときだけマージする" - "(違えば result: head_moved で止まる。読めなければマージせずに 2)", - ) + pm.add_argument("--expect-head-from", metavar="資料", help="導入の確認の承認資料。確かめたコミットが PR の先端のときだけマージする") merge.add_wait_args(pm) pm.set_defaults(func=cmd_promote) pr = sub.add_parser( From eb4cd8aa5ede6f733360a98ef2c28c3baf31e865 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 03:09:09 +0000 Subject: [PATCH 13/35] =?UTF-8?q?docs(ndf):=20=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=AE=E6=98=87=E6=A0=BC=E3=81=AE=E7=B5=8C=E8=B7=AF?= =?UTF-8?q?=E3=82=92=E3=80=81compare=5Fapproved=20=E3=81=A7=E3=81=AF?= =?UTF-8?q?=E3=81=AA=E3=81=8F=20--expect-head-from=20=E3=81=AE=E7=85=A7?= =?UTF-8?q?=E5=90=88=E3=81=AB=E7=9B=B4=E3=81=99=EF=BC=88#1529=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-release-approved-commit.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/specifications/ndf-release-approved-commit.md b/docs/specifications/ndf-release-approved-commit.md index a88382342..9d1ff9820 100644 --- a/docs/specifications/ndf-release-approved-commit.md +++ b/docs/specifications/ndf-release-approved-commit.md @@ -51,7 +51,7 @@ | 扱う | 扱わない | | --- | --- | | `release-steps.py approval-facts` が承認したコミットを書くこと | `--channel dev` の振る舞い(開発版はマージした変更がそのまま載るチャネルで、承認を要しない。引数も結果も変わらない) | -| `release-steps.py approve` が承認の記録を書くこと、`mvv-gate.py check --gate release` が `by: mvv` で書くこと | 昇格の経路(`merged-steps.py promote`)。比較の関数は promote からも呼べる形で `lib/` に置いてある(「決定の理由」) | +| `release-steps.py approve` が承認の記録を書くこと、`mvv-gate.py check --gate release` が `by: mvv` で書くこと | 昇格の経路(`merged-steps.py promote`)。昇格は `compare_approved` を使わず、導入の確認の承認資料の確かめたコミットを `--expect-head-from` で受けて `--expect-head` と同じ照合を使う(#1529。振る舞いの正本は `merged-steps.py` の冒頭) | | `release-steps.py release --channel prod` が承認したコミットを受け取り、承認の外のコミットがあれば止まること | 承認資料のほかの欄、MVV 判定の判定の中身 | | `merged-steps.py merge-when-green --expect-head` が比べた先端だけをマージすること | 食い違ったときに承認資料を自動で作り直して再承認を求める仕組み(止まって人へ戻すまでを扱う) | | 本番のリリースプラン(`supervise.py new release --channel prod`。`--mvv` 付きを含む)が承認資料を渡すこと | | @@ -154,7 +154,7 @@ A = 承認したコミット、T = 比べた先端、H = 配布の PR の先端 | 4 | `git rev-list --no-merges A..T ^H` から、配布の PR の `mergeCommit` と、配布の PR のコミットと同じ `git patch-id --stable` のものを除いた残り | `outside_commits`(I9) | | 5 | `git merge-tree --write-tree A H` の木と `T^{tree}` | 等しければ `match`、違えば `tree_differs`、`merge-tree` が失敗すれば `undecidable` | -`allowed`(入ってよい PR)を空にすると、5 は「T の木が A の木と等しいか」になる(昇格の経路から使う形)。 +`allowed`(入ってよい PR)を空にすると、5 は「T の木が A の木と等しいか」になる(昇格の経路はこの形を使わない。#1529)。 `allowed` が 2 つ以上のときは期待する木を作らず `undecidable` を返す。`git merge-tree --write-tree` は git 2.38 からで、 使えない git では 0 以外を返し `undecidable`(I6)に倒れる。 @@ -241,7 +241,7 @@ git を読むだけで書かず、終了コードも出力も持たない。ブ | 比べる時点は配布の PR のマージの後の 1 つ | 配布の PR はベースブランチへ入るだけで本番へは届かない。マージの前にも比べると判定の時点が 2 つになる。承認したコミットの形と有無・承認の記録だけは最初に確かめ、何もマージせずに止まる | | 判定できないときは承認ゲートで止まる(I6) | 通すと承認の外の変更を見落としうる。失敗(1)にすると、本番のリリースプランの judge が直す(fix)か同じステップをもう一度(retry)を選び、人の承認を経ずに打ち直されうる | | `--mvv` 付きのプランは承認ゲートで `handoff` へ進む(I7) | MVV 判定が承認したのは承認資料の中身で、承認の外のコミットは判定の材料に入っていない。人が外のコミットを見てから承認し直す。`gate_next` を置かないと、10 を受けて `next` の `verify` へ進み、配っていない版の導入を確かめに行く | -| 比べる関数を `lib/` に置く | 昇格の経路にも同じ欠けがあり、`release-steps.py` の中に置くと同じ役割の関数が 2 つになる。`allowed` を空にすれば promote の判定になる | +| 比べる関数を `lib/` に置く | 昇格の経路にも同じ欠けがあり、`release-steps.py` の中に置くと同じ役割の関数が 2 つになる。`allowed` を空にすれば promote の判定になると見込んだが、#1529 は木が等しい別のコミットを通すため採らず、昇格は `--expect-head` の照合を使う | ## 既知の制約 @@ -268,7 +268,7 @@ git を読むだけで書かず、終了コードも出力も持たない。ブ | MVV 判定の記録(I10) | `mvv-gate.py check --gate release` が従うと判定したときだけ承認資料へ `by: mvv` の承認の記録を書き、判定できない場合と `--advise` では書かないこと(`test_mvv_gate.py`) | | 本番のリリースプラン(I7) | 本番の `release` のステップが `--approval <承認資料>` を持ち、`--mvv` 付きは `gate_next: handoff`、無しは `end` であること。開発版のステップに `--approval` も `gate_next` も無いこと(`test_supervise_new.py`) | | 退行しない | `--channel dev` の引数・`items`・`metrics` が変わらないこと。`--channel prod` で配布の PR がマージ済みのとき、打ち直しで続きから進み、一致すれば `ok` になること | -| 昇格の形 | `allowed` を空にしたとき、先端の木が承認したコミットの木と等しいかで判定すること | +| `allowed` が空の形 | `allowed` を空にしたとき、先端の木が承認したコミットの木と等しいかで判定すること | ## 関連リンク From 48ea62b5bea42612e2ffe007afa384f30e3fb093 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 03:31:41 +0000 Subject: [PATCH 14/35] =?UTF-8?q?docs:=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AE=E7=84=A1=E3=81=84=E5=AE=A3=E8=A8=80=E3=81=A7=E6=9C=80?= =?UTF-8?q?=E7=B5=82=E3=82=B9=E3=82=A4=E3=83=BC=E3=83=97=E3=81=8C=E6=8E=A2?= =?UTF-8?q?=E3=81=97=E6=96=B9=E3=81=B8=E9=80=B2=E3=82=80=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=81=A8=E3=80=81=E6=AD=A2=E3=81=BE=E3=81=A3=E3=81=9F=E7=90=86?= =?UTF-8?q?=E7=94=B1=E3=81=AB=E5=85=83=E3=81=AE=E7=B5=82=E4=BA=86=E3=82=B3?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=8C=E6=B7=BB=E3=81=86=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=82=92=E4=BB=95=E6=A7=98=E3=81=B8=E5=86=99=E3=81=99=EF=BC=88?= =?UTF-8?q?#1593=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-supervise-base-sync.md | 1 + plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/specifications/ndf-supervise-base-sync.md b/docs/specifications/ndf-supervise-base-sync.md index 1a805b482..892cb7e48 100644 --- a/docs/specifications/ndf-supervise-base-sync.md +++ b/docs/specifications/ndf-supervise-base-sync.md @@ -282,6 +282,7 @@ sequenceDiagram 止まったときの理由は「ステップ が失敗した(exit=N)」に、そのステップの `summary` と、`on_fail` でそこへ回した直前のステップの `: ` が続く(決定 7)。実装のプランは `base-return` の要約が、検査のプランは `abort` の理由の後に `base-return:` の要約が載る。 +どちらの `summary` も、結果 JSON の `status` が `stopped` で `metrics.exit` が整数なら、後ろへ「(元の終了コード N)」が添う(#1593)。 ## セキュリティ diff --git a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md index 1da272afb..705a758cc 100644 --- a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md +++ b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md @@ -294,7 +294,8 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > > 対象リポジトリに `.ndf/project.json` の `test` があれば、駆動(`drive.py`)がプロンプトに宣言の全体テスト > (テストと静的解析の両方の suite の `command`)を並べる。そのときは並んだコマンドを順に実行し、下の探し方は使わない。 -> 起動の引数 `--verify-command`(実行検証 Step 2.5 の許可の一覧)は、ここでは使わない。宣言が無いときだけ次の手順で探す。 +> 起動の引数 `--verify-command`(実行検証 Step 2.5 の許可の一覧)は、ここでは使わない。宣言が無いときと、テストの無い宣言(`strategy` を書かず、どの suite も +> `command` と `scope_command` を持たない。`{"suites": []}` を含む)のときだけ次の手順で探す(#1593)。 > > 1. 実行手段を探す。`Makefile` の `test` / `lint` / `check` ターゲット、`package.json` の > `scripts`、`pyproject.toml` の `[tool.pytest.ini_options]`、`composer.json` の From 88b933793b4ef00a317b2814d824addb739499d3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 03:42:21 +0000 Subject: [PATCH 15/35] =?UTF-8?q?docs:=20=E4=BF=AE=E6=AD=A3=E3=81=A8?= =?UTF-8?q?=E8=BC=AA=E7=95=AA=E3=81=AE=E6=96=87=E6=9B=B8=E3=82=92=E5=88=86?= =?UTF-8?q?=E5=89=B2=E3=81=AE=E5=9F=BA=E6=BA=96=EF=BC=88500=20=E8=A1=8C?= =?UTF-8?q?=EF=BC=89=E3=81=AB=E5=8F=8E=E3=82=81=E3=82=8B=EF=BC=88#1593?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md index 705a758cc..3935d8dff 100644 --- a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md +++ b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md @@ -293,9 +293,8 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > 対象リポジトリを見て決める。**コマンドを推測して組み立てない。** > > 対象リポジトリに `.ndf/project.json` の `test` があれば、駆動(`drive.py`)がプロンプトに宣言の全体テスト -> (テストと静的解析の両方の suite の `command`)を並べる。そのときは並んだコマンドを順に実行し、下の探し方は使わない。 -> 起動の引数 `--verify-command`(実行検証 Step 2.5 の許可の一覧)は、ここでは使わない。宣言が無いときと、テストの無い宣言(`strategy` を書かず、どの suite も -> `command` と `scope_command` を持たない。`{"suites": []}` を含む)のときだけ次の手順で探す(#1593)。 +> (テストと静的解析の両方の suite の `command`)を並べる。そのときは並んだコマンドを順に実行し、下の探し方は使わない。起動の引数 `--verify-command`(実行検証 Step 2.5 の許可の一覧)は、ここでは使わない。 +> 宣言が無いときと、テストの無い宣言(`strategy` を書かず、どの suite も `command` と `scope_command` を持たない。`{"suites": []}` を含む)のときだけ次の手順で探す(#1593)。 > > 1. 実行手段を探す。`Makefile` の `test` / `lint` / `check` ターゲット、`package.json` の > `scripts`、`pyproject.toml` の `[tool.pytest.ini_options]`、`composer.json` の From 2ffc2436868599f6e8b2d4b2ba7a8ad05adc6f64 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:03:58 +0000 Subject: [PATCH 16/35] =?UTF-8?q?docs:=20=E8=BF=BD=E5=8A=A0=E3=81=97?= =?UTF-8?q?=E3=81=9F=E8=A1=8C=E3=81=8B=E3=82=89=E8=AA=B2=E9=A1=8C=E7=95=AA?= =?UTF-8?q?=E5=8F=B7=E3=81=AE=E7=94=B1=E6=9D=A5=E3=82=92=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-supervise-base-sync.md | 2 +- plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specifications/ndf-supervise-base-sync.md b/docs/specifications/ndf-supervise-base-sync.md index 892cb7e48..a7b98c714 100644 --- a/docs/specifications/ndf-supervise-base-sync.md +++ b/docs/specifications/ndf-supervise-base-sync.md @@ -282,7 +282,7 @@ sequenceDiagram 止まったときの理由は「ステップ が失敗した(exit=N)」に、そのステップの `summary` と、`on_fail` でそこへ回した直前のステップの `: ` が続く(決定 7)。実装のプランは `base-return` の要約が、検査のプランは `abort` の理由の後に `base-return:` の要約が載る。 -どちらの `summary` も、結果 JSON の `status` が `stopped` で `metrics.exit` が整数なら、後ろへ「(元の終了コード N)」が添う(#1593)。 +どちらの `summary` も、結果 JSON の `status` が `stopped` で `metrics.exit` が整数なら、後ろへ「(元の終了コード N)」が添う。 ## セキュリティ diff --git a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md index 3935d8dff..65a6bc557 100644 --- a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md +++ b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md @@ -294,7 +294,7 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > > 対象リポジトリに `.ndf/project.json` の `test` があれば、駆動(`drive.py`)がプロンプトに宣言の全体テスト > (テストと静的解析の両方の suite の `command`)を並べる。そのときは並んだコマンドを順に実行し、下の探し方は使わない。起動の引数 `--verify-command`(実行検証 Step 2.5 の許可の一覧)は、ここでは使わない。 -> 宣言が無いときと、テストの無い宣言(`strategy` を書かず、どの suite も `command` と `scope_command` を持たない。`{"suites": []}` を含む)のときだけ次の手順で探す(#1593)。 +> 宣言が無いときと、テストの無い宣言(`strategy` を書かず、どの suite も `command` と `scope_command` を持たない。`{"suites": []}` を含む)のときだけ次の手順で探す。 > > 1. 実行手段を探す。`Makefile` の `test` / `lint` / `check` ターゲット、`package.json` の > `scripts`、`pyproject.toml` の `[tool.pytest.ini_options]`、`composer.json` の From f237a102c83a1b4ca7f533efedf83ed0f5d2ed37 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:18:17 +0000 Subject: [PATCH 17/35] =?UTF-8?q?Update:=20.ndf/project.json=20=E3=82=92?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=99=A8=E3=81=AE=E7=89=88=202=20=E3=81=A7?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E3=81=97=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手順 0 の check が「古い」(解析器の版 1 < 2・ワークフローと指示書の変更)を返したため、 measure → 答え → write を通した。手の値(test・delivery・issues・ndf_policies)は引き継いだ。 Co-Authored-By: Claude Opus 5.5 --- .ndf/project.json | 77 +++++++++++++++++++++++++++-------------------- 1 file changed, 44 insertions(+), 33 deletions(-) diff --git a/.ndf/project.json b/.ndf/project.json index 819c366f0..5b3e7a03e 100644 --- a/.ndf/project.json +++ b/.ndf/project.json @@ -5,18 +5,18 @@ { "name": "python", "frameworks": [], - "files": 633, + "files": 917, "version": "3.11" }, { "name": "shell", "frameworks": [], - "files": 77 + "files": 79 }, { "name": "javascript", "frameworks": [], - "files": 2 + "files": 8 } ], "test": { @@ -92,9 +92,9 @@ "test_duration": { "measured": [ { - "seconds": 424.0, + "seconds": 1113.0, "source": "ci-steps", - "detail": "run 36284835140(.github/workflows/pytest.yml)のテストの step 4 個の合計" + "detail": "run 40160663747(.github/workflows/pytest.yml)のテストの step 4 個の合計" } ] }, @@ -104,66 +104,77 @@ { "path": ".github/workflows/glossary.yml", "jobs": 1, - "wall_seconds": 12.0 + "wall_seconds": 10.0 }, { "path": ".github/workflows/lint.yml", "jobs": 1, - "wall_seconds": 16.0 + "wall_seconds": 15.0 }, { "path": ".github/workflows/pr-base-guard.yml", "jobs": 1, - "wall_seconds": 9.0 + "wall_seconds": 8.0 }, { "path": ".github/workflows/pr-body-decisions.yml", "jobs": 1, - "wall_seconds": 12.0 + "wall_seconds": 9.0 }, { "path": ".github/workflows/pytest.yml", "jobs": 4, - "wall_seconds": 296.0 + "wall_seconds": 616.0 }, { "path": ".github/workflows/runtime-plugin-authenticated-smoke.yml", "jobs": 1 }, + { + "path": ".github/workflows/runtime-plugin-smoke-latest.yml", + "jobs": 1 + }, { "path": ".github/workflows/runtime-plugin-smoke.yml", "jobs": 5, - "wall_seconds": 60.0 + "wall_seconds": 65.0 }, { "path": ".github/workflows/runtime-plugin-validate.yml", - "jobs": 8, - "wall_seconds": 25.0 + "jobs": 9, + "wall_seconds": 28.0 }, { "path": ".github/workflows/script-structure.yml", "jobs": 1, - "wall_seconds": 13.0 + "wall_seconds": 15.0 }, { "path": "dynamic/pages/pages-build-deployment", "jobs": 3, - "wall_seconds": 29.0 + "wall_seconds": 27.0 } ], "required_checks": [ + "doc-line-limit-check", + "glossary-check", "guard", "instruction-files-check", + "lint", "markdown-link-check", + "pr-body-decisions-check", "pytest", + "required-checks-check", "runtime-plugin-build-check", "runtime-plugin-validate", "runtime-smoke (agy)", "runtime-smoke (claude)", "runtime-smoke (codex)", "runtime-smoke (kiro)", + "script-structure-check", "skill-frontmatter-check", - "skill-repo-assumptions-check" + "skill-repo-assumptions-check", + "skill-shell-vars-check" ] }, "services": { @@ -208,22 +219,23 @@ "notes": [] }, "analysis": { - "analyzer": 1, - "at": "2026-09-27T03:04:04+00:00", + "analyzer": 2, + "at": "2026-10-25T04:18:08+00:00", "inputs": { - ".github/workflows/glossary.yml": "5b79e8c79e18ad4df0d8f2f63e909c4481bf1aa6", + ".github/workflows/glossary.yml": "8ca7da924298f00cf75220e28294a9b0bb473743", ".github/workflows/lint.yml": "56129aea1429a46cadbcdc1d99e02eb264cbbb95", ".github/workflows/pr-base-guard.yml": "71c0cda668b533694c48064813b68bbd03cd6efc", - ".github/workflows/pr-body-decisions.yml": "189d6e18ce07444d9947b72cc4f91e2181aeb8db", - ".github/workflows/pytest.yml": "28b588d100201a77607a6e601bb50ac547243698", - ".github/workflows/runtime-plugin-authenticated-smoke.yml": "1235813a9da744349b7a8a632aebdb7e3398d060", - ".github/workflows/runtime-plugin-smoke.yml": "f5ddec37af8f7893488433084f0a2cb082d3f718", - ".github/workflows/runtime-plugin-validate.yml": "a35749abebcf580d6a7bd00175741e2d69cb78fd", - ".github/workflows/script-structure.yml": "69dbe26a83b9728e823e2f68314ed07e025f62be", - "AGENTS.md": "284d264afd238bb74f2450c8c97c45887d081c19", - "CLAUDE.md": "9ddeb20e7b8480012e218e60891971907f886206", - "KIRO.md": "73383b2d2a593eaaba35d626f73931df78da47c1", - "pyproject.toml": "8070c90c878003392767b4fc877edcd5ab9fd9ce", + ".github/workflows/pr-body-decisions.yml": "4c9a78fae1d3766973daa66110ad9c6a212d0fe6", + ".github/workflows/pytest.yml": "fdd26324b1360e1f56c215873ecff6060b161fbc", + ".github/workflows/runtime-plugin-authenticated-smoke.yml": "15e89f83316c0f275f94c1dfff1981a0add61477", + ".github/workflows/runtime-plugin-smoke-latest.yml": "4495e9fe0d54537c07e98dc93d5592258801261c", + ".github/workflows/runtime-plugin-smoke.yml": "ff951c3adc274597eba75904fe5498f5f9175d4e", + ".github/workflows/runtime-plugin-validate.yml": "ff3bfbce63461fca5e024045154bf53487cf16f6", + ".github/workflows/script-structure.yml": "8d07e6bbe7a20e80da9d8fa830d53be7c698a254", + "AGENTS.md": "1f92a717ed8590f528efe8132975c288a16bf6b9", + "CLAUDE.md": "3e34760f5ad5d1df9f965f1acb795e279babdb1a", + "KIRO.md": "f15c72bb7ec964a5c3b04e649972d3948f03b12a", + "pyproject.toml": "4fe2a1d2450f2ce5a90e34f20340a467e35d0d01", "pytest.ini": "47b2c6e3d414cf343d7f45cda0d08980d28b1a5d" }, "branches": { @@ -234,10 +246,9 @@ ] }, "written": { - "languages": "5d3339f70f1b230e90928da915da309a6854c2277d0c14ba9e8fea42c7cb3a75", - "test": "82dfd6cdb46070f65d57323b597a620812d3dc70f945bf55f727498a1867e0dd", - "test_duration": "fee345186423404ef87f5fc2eaad90581a699fc14222e23c9be0a44d81fe24a4", - "ci": "410acdb0a2a2713bb935fa7d61e39474cb512549a87695d6e6b9c74f9e394db9", + "languages": "320a7cb8a0983114f280919dfd4a931307ee34f149b1983226b870e32817124f", + "test_duration": "3dc9bfb50364d4c9e4fea7b4ec4f17084026c8d9d7168f8c87b33647c60aad01", + "ci": "110c8db4b84130256ae88b38e3e4ece30c91da1c568be8cc6ac8b564864d8ab4", "services": "53f4e4c15a04bde773f9d0d89be06cd65f1a9248c0603b06b78bfa8a5f8a0771", "delivery": "d4f1c6e5121b913e9147eb9552c3c78f2a4944bba1562c14c6a4056b1da7ac77", "issues": "c851aa29d3c11745f1307194db49eba6a488771771ea86c21c7237e400e336b8", From 977507c3b5fc5230c718bc5abbcbd96990a1a736 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:30:08 +0000 Subject: [PATCH 18/35] =?UTF-8?q?Fix:=20test=5Fusage=5Fcapture=20=E3=81=AE?= =?UTF-8?q?=E5=81=BD=E3=81=AE=20CLI=20=E3=81=AB=E5=91=BC=E3=81=B3=E5=87=BA?= =?UTF-8?q?=E3=81=97=E5=85=83=E3=81=AE=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0?= =?UTF-8?q?=E3=82=92=E6=8C=81=E3=81=A1=E8=BE=BC=E3=81=BE=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supervise の中で全体テストを走らせると、NDF_USAGE_SOURCE / NDF_USAGE_KIND が入ったまま test_runtime_specific_usage_shapes が「名乗りが無い」ことを確かめて落ちる。fixture の fake_clis で 4 つの NDF_USAGE_* を外す。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/tests/test_usage_capture.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_usage_capture.py b/plugins/ndf/scripts/tests/test_usage_capture.py index e2445b953..7d8226f42 100644 --- a/plugins/ndf/scripts/tests/test_usage_capture.py +++ b/plugins/ndf/scripts/tests/test_usage_capture.py @@ -94,6 +94,8 @@ def fake_clis(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("CODEX_HOME", str(home / ".codex")) monkeypatch.setenv("SECRET_TOKEN", SECRET) + for name in ("NDF_USAGE_SOURCE", "NDF_USAGE_KIND", "NDF_USAGE_PLAN", "NDF_USAGE_STEP"): + monkeypatch.delenv(name, raising=False) # supervise の中で走らせても名乗りを持ち込まない return tmp_path From 65055e4454ff991e739edc65a601338c4cc08d75 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:47:59 +0000 Subject: [PATCH 19/35] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/approved=5Fcommit.py#compare=5Fappro?= =?UTF-8?q?ved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 段 3(配布の PR の外のコミット I3)を _outside_of_prs、段 4(先端までの外のコミット I9)を _outside_to_tip として抽出した。読めないときは None を返し、compare_approved は undecidable を返す。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/approved_commit.py | 38 +++++++++++++++------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/scripts/lib/approved_commit.py b/plugins/ndf/scripts/lib/approved_commit.py index 32136ee99..a8dc1cbaa 100644 --- a/plugins/ndf/scripts/lib/approved_commit.py +++ b/plugins/ndf/scripts/lib/approved_commit.py @@ -219,6 +219,28 @@ def _expected_tree(root, approved, allowed) -> str | None: return out.split()[0] if out else None +def _outside_of_prs(root, approved, allowed) -> list[str] | None: + """approved..配布の PR の先端のうち、配布の PR のコミットでないもの(I3)。読めなければ None。""" + outside: list[str] = [] + for pr in allowed: + listed = _rev_list(root, f"{approved}..{pr.head}") + if listed is None: + return None + outside += [c for c in listed if c not in pr.commits and c not in outside] + return outside + + +def _outside_to_tip(root, approved, tip, allowed) -> list[str] | None: + """approved..tip の(マージでない)コミットのうち、配布の PR の先端から届かず、配布の PR の mergeCommit でも + 同じ patch-id でもないもの(I9)。読めなければ None。""" + listed = _rev_list(root, "--no-merges", f"{approved}..{tip}", *(f"^{pr.head}" for pr in allowed)) + if listed is None: + return None + merges = {pr.merge_commit for pr in allowed if pr.merge_commit} + patches = {pid for pr in allowed for c in pr.commits if (pid := _patch_id(root, c))} + return [c for c in listed if c not in merges and _patch_id(root, c) not in patches] + + def compare_approved(root, approved: str, tip: str, allowed: list[AllowedPR] = ()) -> Verdict: """承認したコミット approved から比べた先端 tip までを比べる(#815 の設計の「compare の中の順序」)。 @@ -239,19 +261,13 @@ def compare_approved(root, approved: str, tip: str, allowed: list[AllowedPR] = ( if not _ok(root, "merge-base", "--is-ancestor", approved, tip): v.reason = NOT_ANCESTOR return v - outside: list[str] = [] - for pr in allowed: - listed = _rev_list(root, f"{approved}..{pr.head}") - if listed is None: - return v - outside += [c for c in listed if c not in pr.commits and c not in outside] + outside = _outside_of_prs(root, approved, allowed) + if outside is None: + return v if not outside: - listed = _rev_list(root, "--no-merges", f"{approved}..{tip}", *(f"^{pr.head}" for pr in allowed)) - if listed is None: + outside = _outside_to_tip(root, approved, tip, allowed) + if outside is None: return v - merges = {pr.merge_commit for pr in allowed if pr.merge_commit} - patches = {pid for pr in allowed for c in pr.commits if (pid := _patch_id(root, c))} - outside = [c for c in listed if c not in merges and _patch_id(root, c) not in patches] if outside: v.reason = OUTSIDE_COMMITS v.outside = [Outside(c, _subject(root, c)) for c in outside] From e07fb5dd7651109f654104ddbd42237cb071e51e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:48:40 +0000 Subject: [PATCH 20/35] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/merged=5Flib/merge.py#merge=5Fwhen=5Fgre?= =?UTF-8?q?en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit マージの実行部(pin の組み立て・gh pr merge・競合の判定・失敗の emit・merged の記録)を _merge_pr として抽出し、merge_when_green から呼ぶ形にした。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/merged_lib/merge.py | 45 ++++++++++++++----------- 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/plugins/ndf/scripts/merged_lib/merge.py b/plugins/ndf/scripts/merged_lib/merge.py index 1b3168d42..a5d9538fd 100644 --- a/plugins/ndf/scripts/merged_lib/merge.py +++ b/plugins/ndf/scripts/merged_lib/merge.py @@ -183,6 +183,30 @@ def _conflicted(root, n, stderr: str) -> bool: return False +def _merge_pr(root, a, n, watch, items, waits): + """緑を確かめた先頭で gh pr merge を走らせ、マージの記録を items へ足す。失敗すれば stopped を emit する。""" + sha = watch.last_sha # --expect-head があれば、GreenWatch が読むたびにこれと比べている + pin = ["--match-head-commit", sha] if sha else [] # 緑を確かめた先頭(--expect-head なら比べた先端)だけをマージする + p = gh_parts.gh(["pr", "merge", str(n), "--admin", f"--{a.method}", *pin], cwd=root) + if p.returncode != 0: + if _conflicted(root, n, p.stderr or ""): + watch._stop_conflict("gh pr merge がマージのコミットを作れずに拒んだ") + emit( + result( + TOOL, + "stopped", + f"gh pr merge --admin が失敗: {p.stderr.strip()[:300]}", + items + [{"kind": "pr", "name": f"#{n}", "result": "stopped", "reason": p.stderr.strip()[:300]}], + {"waits": waits}, + next=_rerun_superseded_next(n, items), + ) + ) + merged = {"kind": "pr", "name": f"#{n}", "result": "merged", "method": a.method, "head": sha} + if a.gate_approved: + merged["gate_approved"] = a.gate_approved # 誰が承認ゲート 2 を通したか(監査で辿る。#1336 の I8) + items.append(merged) + + def merge_when_green(a, cleanup, next_cmd=None, plan_step="merge-approved"): root = git_root(a.root) n = a.pr @@ -204,26 +228,7 @@ def gate(info): gate_metrics = {"verdict": verdict.value, "target": verdict.target} if verdict else {} if not any(i["kind"] == "pr" and i["result"] == "already_merged" for i in items): - sha = watch.last_sha # --expect-head があれば、GreenWatch が読むたびにこれと比べている - pin = ["--match-head-commit", sha] if sha else [] # 緑を確かめた先頭(--expect-head なら比べた先端)だけをマージする - p = gh_parts.gh(["pr", "merge", str(n), "--admin", f"--{a.method}", *pin], cwd=root) - if p.returncode != 0: - if _conflicted(root, n, p.stderr or ""): - watch._stop_conflict("gh pr merge がマージのコミットを作れずに拒んだ") - emit( - result( - TOOL, - "stopped", - f"gh pr merge --admin が失敗: {p.stderr.strip()[:300]}", - items + [{"kind": "pr", "name": f"#{n}", "result": "stopped", "reason": p.stderr.strip()[:300]}], - {"waits": waits}, - next=_rerun_superseded_next(n, items), - ) - ) - merged = {"kind": "pr", "name": f"#{n}", "result": "merged", "method": a.method, "head": sha} - if a.gate_approved: - merged["gate_approved"] = a.gate_approved # 誰が承認ゲート 2 を通したか(監査で辿る。#1336 の I8) - items.append(merged) + _merge_pr(root, a, n, watch, items, waits) if a.no_cleanup: emit( From 7fbcb5b1d2ff7d2d486b69097d842dad56de2a10 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:49:01 +0000 Subject: [PATCH 21/35] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/skills/cross-refactoring/scripts/refactor=5Flib/?= =?UTF-8?q?template=5Fcheck.py#=5Fcheck=5Fone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 試走の起動部(残り時間の判定・run_within・launch.stop)を _run_probe として抽出し、 (結果, 走らせなかった理由) を返す形にした。記録の辞書の組み立ては _check_one の末尾に残した。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../scripts/refactor_lib/template_check.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py index e09289aa8..6f34a1012 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py @@ -88,6 +88,22 @@ def _reference(baseline: dict[str, Any], suite: ts.Suite, source: Optional[str]) return "green" if whole and status == "green" else "none" +def _run_probe( + command: Optional[str], work: pathlib.Path, log: pathlib.Path, budget: tuple[float, float] +) -> tuple[Optional[ts.Outcome], str]: + """試走を 1 回走らせ、(結果, 走らせなかった理由) を返す。command が無ければ NO_FILES、上限の残りが 1 秒未満なら NO_TIME。""" + if command is None: + return None, NO_FILES + left, started = budget + if left - (time.monotonic() - started) < 1: + return None, NO_TIME + code, timed_out = test_triage.run_within(left, started, lambda limit: run_with_timeout(command, str(work), limit, output=log)) + result = ts.outcome(code, timed_out) + if result.launch_failed: + launch.stop(None, {}, "baseline", command, result, log) + return result, NO_FILES + + def _check_one( strategy: ts.Strategy, suite: ts.Suite, @@ -103,17 +119,7 @@ def _check_one( reference = _reference(baseline, suite, pick.source) command = ts.fill(str(suite.scope_command), pick.paths) if pick.paths else None log = ctx["tmp_dir"] / f"template-check-{i}.log" - result: Optional[ts.Outcome] = None - not_run = NO_FILES - if command is not None: - left, started = budget - if left - (time.monotonic() - started) < 1: - not_run = NO_TIME - else: - code, timed_out = test_triage.run_within(left, started, lambda limit: run_with_timeout(command, str(work), limit, output=log)) - result = ts.outcome(code, timed_out) - if result.launch_failed: - launch.stop(None, {}, "baseline", command, result, log) + result, not_run = _run_probe(command, work, log, budget) known = pick.source == ts.PROBE_FROM_SCOPE and failures is not None verdict = ts.template_verdict(result, reference, known, pick.owned, not_run) return { From 7513b0f15d515ec7df8a10f514f3beda7f8305f1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:49:21 +0000 Subject: [PATCH 22/35] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/approved=5Fcommit.py#verifi?= =?UTF-8?q?ed=5Fsha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 確かめたコミットの行の前後の文言を _VERIFIED_HEAD / _VERIFIED_MID / _VERIFIED_TAIL に置き、 書く側の verified_line と読む側の _VERIFIED_RE をこの定数から組み立てるようにした。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/approved_commit.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/lib/approved_commit.py b/plugins/ndf/scripts/lib/approved_commit.py index a8dc1cbaa..d7c9714f8 100644 --- a/plugins/ndf/scripts/lib/approved_commit.py +++ b/plugins/ndf/scripts/lib/approved_commit.py @@ -156,12 +156,17 @@ def material_row(sha: str) -> tuple[str, str]: # --- 確かめたコミット(導入の確認の承認資料。#1529) ------------------------------ -_VERIFIED_RE = re.compile(r"^- ベースブランチ \S+ の先頭: `([^`]*)`。導入の確認はこのコミットで走らせた$") +_VERIFIED_HEAD = "- ベースブランチ " +_VERIFIED_MID = " の先頭: `" +_VERIFIED_TAIL = "`。導入の確認はこのコミットで走らせた" +_VERIFIED_RE = re.compile( + "^" + re.escape(_VERIFIED_HEAD) + r"\S+" + re.escape(_VERIFIED_MID) + "([^`]*)" + re.escape(_VERIFIED_TAIL) + "$" +) def verified_line(base: str, sha: str) -> str: """導入の確認の承認資料(`release-steps.py verify-facts`)の、確かめたコミットの行。""" - return f"- ベースブランチ {base} の先頭: `{sha}`。導入の確認はこのコミットで走らせた" + return f"{_VERIFIED_HEAD}{base}{_VERIFIED_MID}{sha}{_VERIFIED_TAIL}" def verified_sha(path) -> str: From eb52dcaa88df722c508d5c3486ae8989e015504d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:50:32 +0000 Subject: [PATCH 23/35] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/supervise=5Flib/sprint.py#no=5Fprod=5Fst?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本番のブランチの有無の判定を has_prod_branch として抽出し、sprint_plans の分岐と no_prod_stage の両方から呼ぶ形にした(評価の順序は変えていない)。 Item-Id: I-005 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/sprint.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index e770ab5fe..6eb942d50 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -299,6 +299,11 @@ def advises(a, closing: bool = False) -> bool: return bool(getattr(a, "state", None)) and not closing +def has_prod_branch(a) -> bool: + """本番のブランチ(--production-branch か .ndf/worktree.json の production_branch)があるか。""" + return bool(getattr(a, "production_branch", None)) + + def sprint_state_path(a) -> str: return str(Path(a.state).resolve()) @@ -328,7 +333,7 @@ def sprint_plans(a) -> list[dict]: if not has_release_template(a): return waves + route_waves(a, repo, "検査", note=normal_gate_2(a) if advise else None) release = {"name": "配布", "plans": {"release": plan_sprint_release(a, repo, advise)}, "then_of": "検査"} - if not getattr(a, "production_branch", None): # 本番を組めないなら今のまま配布で終える(#1914 の決定 3) + if not has_prod_branch(a): # 本番を組めないなら今のまま配布で終える(#1914 の決定 3) if advise: release["note"] = normal_gate_2(a) return waves + [release] @@ -469,7 +474,7 @@ def manifest_head(a, pace: str, closing: bool = False) -> dict: def no_prod_stage(a, pace: str, closing: bool) -> bool: """normal の雛形の経路で、本番のブランチが無いため本番のステージを置かなかったか(#1914 の決定 3)。""" - return pace == "normal" and not closing and not getattr(a, "production_branch", None) and has_release_template(a) + return pace == "normal" and not closing and not has_prod_branch(a) and has_release_template(a) def next_text(pace: str, index: list[dict], advise: bool = False, no_prod: bool = False) -> str: From b6f652166d0d38d5908291a4ebf6abc5ef8cf2c6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:51:50 +0000 Subject: [PATCH 24/35] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/supervise=5Flib/sprint=5Fwaves.?= =?UTF-8?q?py#user=5Fgate=5Fcmd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sprint_state_path を sprint.py から sprint_waves.py へ移し、sprint_waves.py の str(Path(a.state).resolve()) の 4 か所をこれに置き換えた。sprint.py は sprint_waves から読み込む。 Item-Id: I-006 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/sprint.py | 5 +---- plugins/ndf/scripts/supervise_lib/sprint_waves.py | 12 ++++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 6eb942d50..aeb6c6318 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -28,6 +28,7 @@ plan_design_results, plan_mvv_design, plan_mvv_release, + sprint_state_path, ) from supervise_lib.new_args import NEW_ARGS from supervise_lib.paths import CHECK_PY, HERE, SELF @@ -304,10 +305,6 @@ def has_prod_branch(a) -> bool: return bool(getattr(a, "production_branch", None)) -def sprint_state_path(a) -> str: - return str(Path(a.state).resolve()) - - PACE_PLANS = {"fast": fast_sprint_plans, "auto": auto_sprint_plans} # 進め方ごとのステージの組み立て(normal は sprint_plans) diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index 46e2b0a5c..5c80b590c 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -335,9 +335,13 @@ def plan_sprint_release(a, repo: str, advise: bool = False, channel: str = "dev" return plan_release(ns) +def sprint_state_path(a) -> str: + return str(Path(a.state).resolve()) + + def user_gate_cmd(a, gate: str, pr: bool) -> str: """利用者の答えを状態へ書くコマンド(覆しの記録。#1400 の AC6)。承認ゲート 1 は答えた設計 PR を --pr で渡す。""" - state = shlex.quote(str(Path(a.state).resolve())) + state = shlex.quote(sprint_state_path(a)) return ( f"python3 {shlex.quote(str(HERE / 'sprint-state.py'))} gate {state} {shlex.quote(gate)} --what <要約> --by user" + (" --pr <設計 PR>" if pr else "") @@ -371,7 +375,7 @@ def normal_gate_2_stage(a, prod: str, advise: bool) -> str: def advise_design_steps(a) -> list[dict]: """助言の MVV 判定(#1400)の mvv・mvv-note のステップ。判定によらず、想定外の失敗でも関門 1 の judge(gate)へ進む。""" - state = shlex.quote(str(Path(a.state).resolve())) + state = shlex.quote(sprint_state_path(a)) note = { "timeout": 300, "cmd": f"sh -c '[ ! -f {MVV_NOTE} ] || gh pr comment {{pr}} --body-file {MVV_NOTE}'", @@ -400,7 +404,7 @@ def plan_mvv_design(a, n: int, repo: str) -> dict: """pace: fast と auto の設計: 関門 1 の judge を MVV 判定のステップへ替える。従えばラベルとコメントを付けてマージする。 ラベルの付与かマージが落ちたら handoff で関門 1 の by: mvv の記録を外し、プランを関門で終える(#1370 の I8)。""" plan = plan_sprint_design(a, n, repo) - state = shlex.quote(str(Path(a.state).resolve())) + state = shlex.quote(sprint_state_path(a)) note = MVV_NOTE # 用語チェックの当たりが直し切れずに残ったら、mvv の判定へ渡さず関門 1 の judge(gate)へ回す push = "" @@ -431,7 +435,7 @@ def plan_mvv_design(a, n: int, repo: str) -> dict: "next": "merge-gate", }, *merge_steps(a, next="end", on_fail="handoff"), - handoff_step(str(Path(a.state).resolve()), "関門 1", "承認ラベルの付与かマージ"), + handoff_step(sprint_state_path(a), "関門 1", "承認ラベルの付与かマージ"), ] plan["規則"] = ( "設計の cross-review は上限 3 ラウンドで関門 1 の判定(mvv のステップ)へ渡す(収束を待たない)。駆動そのものが失敗したら gate。" From f78c2a6b9ae4eed35925b9036ffbb10294bc27d6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:52:18 +0000 Subject: [PATCH 25/35] =?UTF-8?q?Refactor:=20introduce=5Fnamed=5Fconstant?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/skills/cross-refactoring/scripts/ref?= =?UTF-8?q?actor=5Flib/template=5Fcheck.py#=5Freference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 試走の基準の値を test_strategy.py の REFERENCE_GREEN / REFERENCE_RED / REFERENCE_NONE に置き、 template_verdict の比較と _reference の戻り値・比較をこの定数へ置き換えた。値は変えていない。 Item-Id: I-007 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/test_strategy.py | 6 +++++- .../scripts/refactor_lib/template_check.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/lib/test_strategy.py b/plugins/ndf/scripts/lib/test_strategy.py index e91ba8d72..2dfd825dd 100644 --- a/plugins/ndf/scripts/lib/test_strategy.py +++ b/plugins/ndf/scripts/lib/test_strategy.py @@ -353,6 +353,10 @@ def scope_runs(strategy: Strategy, targets: list[str], changed: list[str]) -> li PROBE_FROM_SCOPE = "scope" PROBE_FROM_REPO = "repo" PROBE_FROM_PATHS = "paths" +# 試走の基準(着手前のテストの成否。`template_verdict` が読む)。 +REFERENCE_GREEN = "green" +REFERENCE_RED = "red" +REFERENCE_NONE = "none" # 試走に入れるテストのファイルの最大の本数。1 本は受けて 2 本目を受けない雛形(`discover -s a b`)を見分ける。 PROBE_MAX_FILES = 2 @@ -445,7 +449,7 @@ def template_verdict( code = f"終了コード {result.code}" if not owned: return ProbeVerdict(PROBE_UNDECIDED, f"試走が{code}で落ちたが、入れたファイルをこの suite が受け持つと言い切れない") - if reference == "green": + if reference == REFERENCE_GREEN: return ProbeVerdict(PROBE_DEFECT, f"着手前のテストは通り、試走が{code}で落ちた") if failures_known: return ProbeVerdict(PROBE_DEFECT, f"既存失敗のファイルを除いた試走が{code}で落ちた") diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py index 6f34a1012..595fb5a1d 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py @@ -83,9 +83,9 @@ def _reference(baseline: dict[str, Any], suite: ts.Suite, source: Optional[str]) """基準。`--scope` の候補はその suite の着手前のテストの成否、ほかは全体(`command`)で通ったときだけ `green`。""" status = (baseline.get("suites") or {}).get(suite.name) if source == ts.PROBE_FROM_SCOPE: - return status if status in ("green", "red") else "none" + return status if status in (ts.REFERENCE_GREEN, ts.REFERENCE_RED) else ts.REFERENCE_NONE whole = baseline.get("mode") in ("whole", "round") and bool(suite.command) - return "green" if whole and status == "green" else "none" + return ts.REFERENCE_GREEN if whole and status == ts.REFERENCE_GREEN else ts.REFERENCE_NONE def _run_probe( From c9dd3a90b9b2a529f9fbdbd817ce6270597f346d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:52:35 +0000 Subject: [PATCH 26/35] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/test=5Fstrategy.py#probe=5Froots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paths の要素の正規化を _root_of、全体を指すかの判定を _is_whole_root として抽出し、 _declares_whole と probe_roots の両方から使う形にした。 Item-Id: I-008 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/test_strategy.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/lib/test_strategy.py b/plugins/ndf/scripts/lib/test_strategy.py index 2dfd825dd..165e384bb 100644 --- a/plugins/ndf/scripts/lib/test_strategy.py +++ b/plugins/ndf/scripts/lib/test_strategy.py @@ -382,15 +382,25 @@ def stops(self) -> bool: return self.status in (PROBE_DEFECT, PROBE_UNDECIDED) +def _root_of(p) -> str: + """suite の `paths` の要素の前後の空白と末尾の `/` を外した形。""" + return str(p).strip().rstrip("/") + + +def _is_whole_root(r: str) -> bool: + """`_root_of` の形がリポジトリ全体(`.` か空)を指すか。""" + return r in (".", "") + + def _declares_whole(suite: Suite) -> bool: """suite の `paths` が `.` か空(`suite_for` が全てのファイルを当てる)か。""" - return not suite.paths or any(str(p).strip().rstrip("/") in (".", "") for p in suite.paths) + return not suite.paths or any(_is_whole_root(_root_of(p)) for p in suite.paths) def probe_roots(suite: Suite) -> list[str]: """suite の `paths` のうち、配下の追跡ファイルを候補にしてよい要素(`.` と glob を除く)。""" - roots = [str(p).strip().rstrip("/") for p in suite.paths] - return [r for r in roots if r not in (".", "") and not any(c in r for c in _GLOB_CHARS)] + roots = [_root_of(p) for p in suite.paths] + return [r for r in roots if not _is_whole_root(r) and not any(c in r for c in _GLOB_CHARS)] def probe_paths( From 2225d607f8273b87099adf4144997dea68d5df17 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:52:51 +0000 Subject: [PATCH 27/35] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/supervise=5Flib/engine.py#=5Fre?= =?UTF-8?q?sult=5Fsummary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit metrics を先に 1 度だけ読み、isinstance の判定と重なっていた `or {}` を外した。 Item-Id: I-009 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/engine.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/scripts/supervise_lib/engine.py b/plugins/ndf/scripts/supervise_lib/engine.py index b0625872d..d7934fea2 100644 --- a/plugins/ndf/scripts/supervise_lib/engine.py +++ b/plugins/ndf/scripts/supervise_lib/engine.py @@ -34,7 +34,8 @@ def _result_summary(text: str) -> str: """ステップの出力の最後の JSON の summary。`status` が `stopped` で `metrics.exit` が整数なら「(元の終了コード N)」を添える。""" res = last_json(text) or {} summary = str(res.get("summary") or "") - code = (res.get("metrics") or {}).get("exit") if isinstance(res.get("metrics"), dict) else None + metrics = res.get("metrics") + code = metrics.get("exit") if isinstance(metrics, dict) else None if summary and res.get("status") == "stopped" and isinstance(code, int) and not isinstance(code, bool): return f"{summary}(元の終了コード {code})" return summary From fa843e2af200e4bd633e51845295bdbbda1ce0f5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:53:11 +0000 Subject: [PATCH 28/35] =?UTF-8?q?Refactor:=20introduce=5Fparameter=5Fobjec?= =?UTF-8?q?t=20=E2=80=94=20plugins/ndf/skills/cross-refactoring/scripts/re?= =?UTF-8?q?factor=5Flib/template=5Fcheck.py#check=5Ftemplates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check_templates が _check_one へ渡す文字列キーの dict を、frozen の dataclass _ProbeContext に 置き換え、_check_one の ctx["..."] を属性参照にした。 Item-Id: I-010 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../scripts/refactor_lib/template_check.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py index 595fb5a1d..bce475fa2 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py @@ -42,6 +42,18 @@ class Report: path: Optional[pathlib.Path] = None +@dataclass(frozen=True) +class _ProbeContext: + """`check_templates` が suite ごとの試走(`_check_one`)へ渡す、suite によらない入力。""" + + baseline: dict[str, Any] + work: pathlib.Path + scope: list[str] + decl: dict[str, Any] + flag: Optional[str] + tmp_dir: pathlib.Path + + def targets_of(strategy: ts.Strategy) -> list[ts.Suite]: """試走の対象の suite。`round-only` は改修の範囲テストを使わないため、静的解析は `{paths}` に変更したファイルが入るため対象にしない。""" if strategy.name == ts.ROUND_ONLY: @@ -107,18 +119,18 @@ def _run_probe( def _check_one( strategy: ts.Strategy, suite: ts.Suite, - ctx: dict[str, Any], + ctx: _ProbeContext, budget: tuple[float, float], i: int, ) -> dict[str, Any]: - baseline, work = ctx["baseline"], ctx["work"] + baseline, work = ctx.baseline, ctx.work failures = baseline.get("existing_failures") failing = list(test_triage.by_file(list(failures or []))) - pick = ts.probe_paths(strategy, suite, *_probe_candidates(strategy, suite, ctx["scope"], work), failing) - index, fix_at = _fix_at(strategy, ctx["decl"], suite, ctx["flag"]) + pick = ts.probe_paths(strategy, suite, *_probe_candidates(strategy, suite, ctx.scope, work), failing) + index, fix_at = _fix_at(strategy, ctx.decl, suite, ctx.flag) reference = _reference(baseline, suite, pick.source) command = ts.fill(str(suite.scope_command), pick.paths) if pick.paths else None - log = ctx["tmp_dir"] / f"template-check-{i}.log" + log = ctx.tmp_dir / f"template-check-{i}.log" result, not_run = _run_probe(command, work, log, budget) known = pick.source == ts.PROBE_FROM_SCOPE and failures is not None verdict = ts.template_verdict(result, reference, known, pick.owned, not_run) @@ -182,7 +194,7 @@ def check_templates( return Report(False) left = float(timeout) - float(baseline.get("seconds") or 0.0) budget = (left, time.monotonic()) - ctx = {"baseline": baseline, "work": work, "scope": list(scope), "decl": decl or {}, "flag": flag, "tmp_dir": tmp_dir} + ctx = _ProbeContext(baseline, work, list(scope), decl or {}, flag, tmp_dir) rows = [_check_one(strategy, suite, ctx, budget, i) for i, suite in enumerate(suites)] # 試走が書いた JUnit を残さない(後の手順が前の実行の結果を読まないため) test_triage.clear_junit(str(work), strategy) From c3bd9dc1fa3bcfea6712a58c075fd7e8fc1a1d82 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:57:09 +0000 Subject: [PATCH 29/35] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/approved=5Fcommit.py#verified=5Fsha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _VERIFIED_RE の組み立てを ruff format の形(1 行)へ整えた。中身の式は変えていない。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/lib/approved_commit.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/lib/approved_commit.py b/plugins/ndf/scripts/lib/approved_commit.py index d7c9714f8..02e3feeba 100644 --- a/plugins/ndf/scripts/lib/approved_commit.py +++ b/plugins/ndf/scripts/lib/approved_commit.py @@ -159,9 +159,7 @@ def material_row(sha: str) -> tuple[str, str]: _VERIFIED_HEAD = "- ベースブランチ " _VERIFIED_MID = " の先頭: `" _VERIFIED_TAIL = "`。導入の確認はこのコミットで走らせた" -_VERIFIED_RE = re.compile( - "^" + re.escape(_VERIFIED_HEAD) + r"\S+" + re.escape(_VERIFIED_MID) + "([^`]*)" + re.escape(_VERIFIED_TAIL) + "$" -) +_VERIFIED_RE = re.compile("^" + re.escape(_VERIFIED_HEAD) + r"\S+" + re.escape(_VERIFIED_MID) + "([^`]*)" + re.escape(_VERIFIED_TAIL) + "$") def verified_line(base: str, sha: str) -> str: From 0621051f8c6fe4318b1987a7eaeea1b246014096 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:57:09 +0000 Subject: [PATCH 30/35] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/approved=5Fcommit.py#compare=5Fapproved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lint の違反は approved_commit.py の _VERIFIED_RE(I-004 の行)の整形だけで、I-004 の修正で解消した。 compare_approved の抽出(I-001)には直す箇所が無いため、変更の無いコミットで記録する。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: default From c1ca4ffc0aa24fc98cd3f00abab4cb1669829d03 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 04:58:42 +0000 Subject: [PATCH 31/35] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/supervise=5Flib/sprint=5Fwaves.py#user=5Fgate?= =?UTF-8?q?=5Fcmd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sprint_state_path を足した sprint_waves.py が 503 行になり、行数の上限(500 行)を超えた。 sprint_state_path を新しいモジュール supervise_lib/state_path.py へ移し、sprint_waves.py と sprint.py はそこから読み込む。本体は変えていない(500 行に収まる)。 Item-Id: I-006 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/supervise_lib/sprint.py | 2 +- plugins/ndf/scripts/supervise_lib/sprint_waves.py | 5 +---- plugins/ndf/scripts/supervise_lib/state_path.py | 9 +++++++++ 3 files changed, 11 insertions(+), 5 deletions(-) create mode 100644 plugins/ndf/scripts/supervise_lib/state_path.py diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index aeb6c6318..884b693b2 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -28,12 +28,12 @@ plan_design_results, plan_mvv_design, plan_mvv_release, - sprint_state_path, ) from supervise_lib.new_args import NEW_ARGS from supervise_lib.paths import CHECK_PY, HERE, SELF from supervise_lib.procedures import sprint_out, with_record from supervise_lib.release_templates import release_verify_step +from supervise_lib.state_path import sprint_state_path from supervise_lib.sprint_routes import ( MVV_PACES, dev_channel_of, diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index 5c80b590c..28eebbf1e 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -15,6 +15,7 @@ from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, HERE, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP from supervise_lib.procedures import DESIGN_RESULTS, pr_step, requirements_path, sprint_materials, sprint_out, with_record from supervise_lib.release_templates import MVV_NOTE, advise_steps, plan_release +from supervise_lib.state_path import sprint_state_path from supervise_lib.templates import plan_check, plan_check_since, plan_impl from supervise_lib.verify_steps import handoff_step, merge_steps @@ -335,10 +336,6 @@ def plan_sprint_release(a, repo: str, advise: bool = False, channel: str = "dev" return plan_release(ns) -def sprint_state_path(a) -> str: - return str(Path(a.state).resolve()) - - def user_gate_cmd(a, gate: str, pr: bool) -> str: """利用者の答えを状態へ書くコマンド(覆しの記録。#1400 の AC6)。承認ゲート 1 は答えた設計 PR を --pr で渡す。""" state = shlex.quote(sprint_state_path(a)) diff --git a/plugins/ndf/scripts/supervise_lib/state_path.py b/plugins/ndf/scripts/supervise_lib/state_path.py new file mode 100644 index 000000000..ea37e9843 --- /dev/null +++ b/plugins/ndf/scripts/supervise_lib/state_path.py @@ -0,0 +1,9 @@ +"""スプリントの状態ファイルの絶対パス(sprint.py と sprint_waves.py が共有する)。""" + +from __future__ import annotations + +from pathlib import Path + + +def sprint_state_path(a) -> str: + return str(Path(a.state).resolve()) From f561d2a8f94e74d16c9b335973be4700d2c85a1e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 05:11:41 +0000 Subject: [PATCH 32/35] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/supervise=5Flib/sprint=5Fwaves.py#user=5Fgate?= =?UTF-8?q?=5Fcmd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前回の修正で足した supervise_lib/state_path.py が、パッケージのモジュール一覧の設計 (test_supervise_layout の test_package_has_the_designed_modules)に無いため落ちた。 sprint_state_path を既存の supervise_lib/plan.py(supervise_lib を何も import しないので 循環しない)へ移し、state_path.py を消す。sprint.py と sprint_waves.py は plan から読み込む。 本体は変えていない。 Item-Id: I-006 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/supervise_lib/plan.py | 6 ++++++ plugins/ndf/scripts/supervise_lib/sprint.py | 2 +- plugins/ndf/scripts/supervise_lib/sprint_waves.py | 2 +- plugins/ndf/scripts/supervise_lib/state_path.py | 9 --------- 4 files changed, 8 insertions(+), 11 deletions(-) delete mode 100644 plugins/ndf/scripts/supervise_lib/state_path.py diff --git a/plugins/ndf/scripts/supervise_lib/plan.py b/plugins/ndf/scripts/supervise_lib/plan.py index 8554b2281..d70738778 100644 --- a/plugins/ndf/scripts/supervise_lib/plan.py +++ b/plugins/ndf/scripts/supervise_lib/plan.py @@ -198,11 +198,17 @@ from __future__ import annotations import re +from pathlib import Path # queue が --then のプランを流す前に、先行のプランの Pull Request の番号へ置き換える印 QUEUE_PRS = "{queue_prs}" +def sprint_state_path(a) -> str: + """スプリントの状態ファイルの絶対パス(sprint.py と sprint_waves.py が共有する)。""" + return str(Path(a.state).resolve()) + + def _part_prompt(base_prompt: str, i: int, parts: list[dict]) -> str: """i 番目(1 始まり)のパートの prompt。元の prompt へ、触るファイルと終え方を足す。""" part = parts[i - 1] diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 884b693b2..4386a0f2a 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -33,7 +33,7 @@ from supervise_lib.paths import CHECK_PY, HERE, SELF from supervise_lib.procedures import sprint_out, with_record from supervise_lib.release_templates import release_verify_step -from supervise_lib.state_path import sprint_state_path +from supervise_lib.plan import sprint_state_path from supervise_lib.sprint_routes import ( MVV_PACES, dev_channel_of, diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index 28eebbf1e..72f123ba4 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -15,7 +15,7 @@ from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, HERE, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP from supervise_lib.procedures import DESIGN_RESULTS, pr_step, requirements_path, sprint_materials, sprint_out, with_record from supervise_lib.release_templates import MVV_NOTE, advise_steps, plan_release -from supervise_lib.state_path import sprint_state_path +from supervise_lib.plan import sprint_state_path from supervise_lib.templates import plan_check, plan_check_since, plan_impl from supervise_lib.verify_steps import handoff_step, merge_steps diff --git a/plugins/ndf/scripts/supervise_lib/state_path.py b/plugins/ndf/scripts/supervise_lib/state_path.py deleted file mode 100644 index ea37e9843..000000000 --- a/plugins/ndf/scripts/supervise_lib/state_path.py +++ /dev/null @@ -1,9 +0,0 @@ -"""スプリントの状態ファイルの絶対パス(sprint.py と sprint_waves.py が共有する)。""" - -from __future__ import annotations - -from pathlib import Path - - -def sprint_state_path(a) -> str: - return str(Path(a.state).resolve()) From d2ab411e8b34891f45184b685d8bb4b83e74456e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 05:33:59 +0000 Subject: [PATCH 33/35] =?UTF-8?q?Fix:=20=E9=96=A2=E9=96=80=202=20=E3=81=AE?= =?UTF-8?q?=E6=96=87=E3=81=AB=E6=89=BF=E8=AA=8D=E3=81=AE=E8=A8=98=E9=8C=B2?= =?UTF-8?q?=EF=BC=88release-steps.py=20approve=EF=BC=89=E3=82=92=E6=9C=AC?= =?UTF-8?q?=E7=95=AA=E3=81=AE=E5=89=8D=E3=81=AB=E6=89=93=E3=81=A4=E6=89=8B?= =?UTF-8?q?=E9=A0=86=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本番のプランの release --channel prod --approval は承認資料に承認の記録が無いと gate(10)で止まるため、 承認されたら approve で記録してから「本番」のステージの command を打つ形にする(PR #2133 のレビュー指摘)。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/sprint_waves.py | 4 ++-- .../fixtures/supervise-contract/plans/sprint/sprint.json | 2 +- plugins/ndf/scripts/tests/test_project_mvv_normal.py | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index 72f123ba4..06a8790c2 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -364,8 +364,8 @@ def normal_gate_2_stage(a, prod: str, advise: bool) -> str: """pace: normal の承認ゲート 2 のステージの文。承認の手順はここの 1 か所に置き、配布の note に重ねない(#1914 の決定 1)。""" approval = f"issues/approval-{(a.release or {}).get('plugin')}-v{prod}.md" text = ( - f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら" - "「本番」のステージの command を打つ(差し戻されたら打たない)" + f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {approval} " + "--approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" ) return text + "。" + normal_gate_2(a) if advise else text diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json index 0b965e080..b701b84be 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json @@ -69,7 +69,7 @@ { "wave": 8, "name": "関門 2", - "gate": "開発版の配布が書いた承認資料(issues/approval-foo-v1.2.0.md)を示して本番への配布の承認を取り、承認されたら「本番」のステージの command を打つ(差し戻されたら打たない)" + "gate": "開発版の配布が書いた承認資料(issues/approval-foo-v1.2.0.md)を示して本番への配布の承認を取り、承認されたら `python3 /scripts/release-steps.py approve --approval issues/approval-foo-v1.2.0.md --approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" }, { "wave": 9, diff --git a/plugins/ndf/scripts/tests/test_project_mvv_normal.py b/plugins/ndf/scripts/tests/test_project_mvv_normal.py index ae30c906f..eb2ce40b8 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv_normal.py +++ b/plugins/ndf/scripts/tests/test_project_mvv_normal.py @@ -321,6 +321,7 @@ def test_normal_sprint_with_a_state_puts_the_advice_before_both_gates(tmp_path): waves = {w["name"]: w for w in manifest["ステージ"]} assert "--what <要約> --by user --pr <設計 PR>" in waves["関門 1"]["gate"] and "--by user" in waves["関門 2"]["gate"] assert "note" not in waves["配布"] and "'関門 2' --what" in waves["関門 2"]["gate"] + assert waves["関門 2"]["gate"].index("release-steps.py approve --approval") < waves["関門 2"]["gate"].index("「本番」のステージ") assert "sprint-state.py gate" in out["next"] design = load(waves["設計"]["plans"][0]) ds = steps_of(design) From 2889d4568cfe34e6c673fac8c4f073cbc1b67b5b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 05:39:33 +0000 Subject: [PATCH 34/35] =?UTF-8?q?Fix:=20develop=20=E3=82=92=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E8=BE=BC=E3=82=93=E3=81=A0=E5=BE=8C=E3=81=AE=20CI=20?= =?UTF-8?q?=E3=81=AE=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop の design_read で sprint_waves.py が 505 行になり(上限 500)、sprint.py の HERE が使われなくなった。 関門の文の関数(user_gate_cmd・normal_gate_1・normal_gate_2・normal_gate_2_stage)を sprint_routes.py へ移し、 sprint.py の使われない import を外す。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/sprint.py | 8 ++-- .../scripts/supervise_lib/sprint_routes.py | 39 ++++++++++++++++++- .../ndf/scripts/supervise_lib/sprint_waves.py | 36 +---------------- 3 files changed, 43 insertions(+), 40 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 58c91d6d9..6818b3826 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -21,9 +21,6 @@ plan_sprint_design, plan_sprint_impl, plan_sprint_release, - normal_gate_1, - normal_gate_2, - normal_gate_2_stage, plan_advise_design, design_read, plan_design_results, @@ -31,11 +28,14 @@ plan_mvv_release, ) from supervise_lib.new_args import NEW_ARGS -from supervise_lib.paths import CHECK_PY, HERE, SELF +from supervise_lib.paths import CHECK_PY, SELF from supervise_lib.procedures import sprint_out, with_record from supervise_lib.release_templates import release_verify_step from supervise_lib.plan import sprint_state_path from supervise_lib.sprint_routes import ( + normal_gate_1, + normal_gate_2, + normal_gate_2_stage, MVV_PACES, dev_channel_of, has_record, diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index bc13228a5..0486f3a7d 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -2,11 +2,14 @@ from __future__ import annotations +import shlex + import delivery from pace import PaceError, read_pace from sprint_mvv import MVV_PACES from supervise_lib.decl import DeclError, decl_roots, delivery_decl, require_versions -from supervise_lib.plan import QUEUE_PRS +from supervise_lib.paths import HERE +from supervise_lib.plan import QUEUE_PRS, sprint_state_path from supervise_lib.delivery_templates import GATE_2_MATERIAL, plan_gate_2, plan_promote, plan_verify from supervise_lib.release_templates import RELEASE_FORMS from supervise_lib.verify_steps import plan_limits @@ -163,3 +166,37 @@ def manual_production_waves(a, repo: str, then_of: str, mvv: str, condition: dic }, ] return waves + + +def user_gate_cmd(a, gate: str, pr: bool) -> str: + """利用者の答えを状態へ書くコマンド(覆しの記録。#1400 の AC6)。承認ゲート 1 は答えた設計 PR を --pr で渡す。""" + state = shlex.quote(sprint_state_path(a)) + return ( + f"python3 {shlex.quote(str(HERE / 'sprint-state.py'))} gate {state} {shlex.quote(gate)} --what <要約> --by user" + + (" --pr <設計 PR>" if pr else "") + + " --outcome approved|rejected" + ) + + +def normal_gate_1(a) -> str: + return ( + "設計 Pull Request をまとめて承認してマージする。承認資料には各設計 PR の助言の MVV 判定(PR のコメント。無ければ「MVV なし」)を" + f"載せる。利用者が答えたら、設計 PR ごとに `{user_gate_cmd(a, '関門 1', True)}` を打つ" + ) + + +def normal_gate_2(a) -> str: + return ( + "承認ゲート 2: 承認資料の末尾に助言の MVV 判定が載る(無ければ「MVV なし」)。利用者が本番への配布に答えたら、" + f"`{user_gate_cmd(a, '関門 2', False)}` を打つ" + ) + + +def normal_gate_2_stage(a, prod: str, advise: bool) -> str: + """pace: normal の承認ゲート 2 のステージの文。承認の手順はここの 1 か所に置き、配布の note に重ねない(#1914 の決定 1)。""" + approval = f"issues/approval-{(a.release or {}).get('plugin')}-v{prod}.md" + text = ( + f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {approval} " + "--approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" + ) + return text + "。" + normal_gate_2(a) if advise else text diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index 654450864..bc89f05a2 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -12,7 +12,7 @@ import legacy_names from supervise_lib.decl import decl_fields -from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, HERE, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP +from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP from supervise_lib.procedures import DESIGN_RESULTS, pr_step, requirements_path, sprint_materials, sprint_out, with_record from supervise_lib.release_templates import MVV_NOTE, advise_steps, plan_release from supervise_lib.plan import sprint_state_path @@ -341,40 +341,6 @@ def plan_sprint_release(a, repo: str, advise: bool = False, channel: str = "dev" return plan_release(ns) -def user_gate_cmd(a, gate: str, pr: bool) -> str: - """利用者の答えを状態へ書くコマンド(覆しの記録。#1400 の AC6)。承認ゲート 1 は答えた設計 PR を --pr で渡す。""" - state = shlex.quote(sprint_state_path(a)) - return ( - f"python3 {shlex.quote(str(HERE / 'sprint-state.py'))} gate {state} {shlex.quote(gate)} --what <要約> --by user" - + (" --pr <設計 PR>" if pr else "") - + " --outcome approved|rejected" - ) - - -def normal_gate_1(a) -> str: - return ( - "設計 Pull Request をまとめて承認してマージする。承認資料には各設計 PR の助言の MVV 判定(PR のコメント。無ければ「MVV なし」)を" - f"載せる。利用者が答えたら、設計 PR ごとに `{user_gate_cmd(a, '関門 1', True)}` を打つ" - ) - - -def normal_gate_2(a) -> str: - return ( - "承認ゲート 2: 承認資料の末尾に助言の MVV 判定が載る(無ければ「MVV なし」)。利用者が本番への配布に答えたら、" - f"`{user_gate_cmd(a, '関門 2', False)}` を打つ" - ) - - -def normal_gate_2_stage(a, prod: str, advise: bool) -> str: - """pace: normal の承認ゲート 2 のステージの文。承認の手順はここの 1 か所に置き、配布の note に重ねない(#1914 の決定 1)。""" - approval = f"issues/approval-{(a.release or {}).get('plugin')}-v{prod}.md" - text = ( - f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {approval} " - "--approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" - ) - return text + "。" + normal_gate_2(a) if advise else text - - def advise_design_steps(a) -> list[dict]: """助言の MVV 判定(#1400)の mvv・mvv-note のステップ。判定によらず、想定外の失敗でも関門 1 の judge(gate)へ進む。""" state = shlex.quote(sprint_state_path(a)) From b8303994d9f1a94ccca3cd8ef8d313a8c08196cf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 25 Oct 2026 05:53:09 +0000 Subject: [PATCH 35/35] =?UTF-8?q?fix(ndf):=20=E9=96=A2=E9=96=80=202=20?= =?UTF-8?q?=E3=81=AE=E6=96=87=E3=81=AE=E6=89=BF=E8=AA=8D=E8=B3=87=E6=96=99?= =?UTF-8?q?=E3=82=92=E5=85=83=E3=81=AE=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88?= =?UTF-8?q?=E3=83=AA=E3=81=AE=E4=B8=8B=E3=81=AE=E7=B5=B6=E5=AF=BE=E3=83=91?= =?UTF-8?q?=E3=82=B9=E3=81=A7=E7=A4=BA=E3=81=99=20(#1914)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normal_gate_2_stage が承認資料を相対パスで approve へ渡していたため、worktree で打つと 作業場所の git の根から解かれ、開発版の配布が書いた資料を見つけられなかった。 本番の release と同じ material_path で絶対パスにする。 Co-Authored-By: Claude Opus 5.5 --- .../ndf/scripts/supervise_lib/release_templates.py | 10 +++++----- plugins/ndf/scripts/supervise_lib/sprint.py | 2 +- plugins/ndf/scripts/supervise_lib/sprint_routes.py | 11 ++++++----- .../supervise-contract/plans/sprint/sprint.json | 2 +- plugins/ndf/scripts/tests/test_project_mvv_normal.py | 3 +++ 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index 8cfac2539..7a8924ccd 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -128,7 +128,7 @@ def _add_prod_mvv_gate(steps: list[dict], a, repo: str | None, approval: str, pr # 関門 2 を MVV で判定する。関門(10)なら報告は 結果: 関門 で止まり、conductor が承認を取ってから # run <計画> --from bump で続ける。従えば判定の記録を --pr の PR のすべてへコメントしてから bump へ進み、 # コメントが落ちたら handoff が関門 2 の by: mvv の記録を外して関門で終える(#1370 の I8) - material = _material_path(repo, approval) + material = material_path(repo, approval) state = str(Path(a.mvv).resolve()) steps[0:0] = mvv_gate_steps( f"{MVV_PY} check --sprint {shlex.quote(state)} --gate release " @@ -165,7 +165,7 @@ def _prepare_steps(a, plugin: str, v: str, prs: str, dev: bool, after_notes: str "id": "bump-others", "type": "run", "stage": "配布", - "cmd": bump_others_cmd(a, plugin, _material_path(repo, approval)), + "cmd": bump_others_cmd(a, plugin, material_path(repo, approval)), "on_fail": "judge", "next": "changelog", } @@ -201,7 +201,7 @@ def _release_step(a, v: str, dev: bool, repo: str | None, approval: str) -> dict "probe": {"cmd": f"{MERGED_PY} probe --head {{branch}} --head {{base}} --act"}, } if not dev: - step["cmd"] += f" --approval {shlex.quote(_material_path(repo, approval))}" + step["cmd"] += f" --approval {shlex.quote(material_path(repo, approval))}" step["gate_next"] = "handoff" if getattr(a, "mvv", None) else "end" return step @@ -373,7 +373,7 @@ def advise_steps(state: str, gate: str, args: str, note: dict, head: dict | None def advise_release_steps(a, repo: str | None, approval: str, prs: str) -> list[dict]: """開発版の explain の後の助言の MVV 判定(#1400)。承認資料と出す版の PR を材料にし、判定の記録を承認資料の末尾へ足す。 判定によらず、想定外の失敗でも mvv-note へ進み、プランの結果は facts の関門のままである。""" - material = _material_path(repo, approval) + material = material_path(repo, approval) state = shlex.quote(str(Path(a.advise).resolve())) args = f"--material {shlex.quote(material)} --pr {prs} --mode {a.mode}" + (f" --root {shlex.quote(repo)}" if repo else "") note = { @@ -411,7 +411,7 @@ def mvv_gate_steps(mvv_cmd: str, note_cmd: str, next_id: str) -> list[dict]: ] -def _material_path(repo, approval): +def material_path(repo, approval): """承認資料の置き場。元のリポジトリが分かればその下。""" return f"{repo}/{approval}" if repo else approval diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 6818b3826..e20caee95 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -268,7 +268,7 @@ def sprint_plans(a) -> list[dict]: prod = prod_version(a.version) return waves + [ release, - {"name": "関門 2", "gate": normal_gate_2_stage(a, prod, advise)}, + {"name": "関門 2", "gate": normal_gate_2_stage(a, repo, prod, advise)}, {"name": "本番", "plans": {"release-prod": plan_sprint_release(a, repo, channel="prod", version=prod)}}, ] diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index 0486f3a7d..a66d60660 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -11,7 +11,7 @@ from supervise_lib.paths import HERE from supervise_lib.plan import QUEUE_PRS, sprint_state_path from supervise_lib.delivery_templates import GATE_2_MATERIAL, plan_gate_2, plan_promote, plan_verify -from supervise_lib.release_templates import RELEASE_FORMS +from supervise_lib.release_templates import RELEASE_FORMS, material_path from supervise_lib.verify_steps import plan_limits @@ -192,11 +192,12 @@ def normal_gate_2(a) -> str: ) -def normal_gate_2_stage(a, prod: str, advise: bool) -> str: - """pace: normal の承認ゲート 2 のステージの文。承認の手順はここの 1 か所に置き、配布の note に重ねない(#1914 の決定 1)。""" - approval = f"issues/approval-{(a.release or {}).get('plugin')}-v{prod}.md" +def normal_gate_2_stage(a, repo: str | None, prod: str, advise: bool) -> str: + """pace: normal の承認ゲート 2 のステージの文。承認の手順はここの 1 か所に置き、配布の note に重ねない(#1914 の決定 1)。 + 承認資料は本番のプランの release と同じく元のリポジトリの下の絶対パスで示す(作業場所の git の根に依らない)。""" + approval = material_path(repo, f"issues/approval-{(a.release or {}).get('plugin')}-v{prod}.md") text = ( - f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {approval} " + f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {shlex.quote(approval)} " "--approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" ) return text + "。" + normal_gate_2(a) if advise else text diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json index b701b84be..206aa1645 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json @@ -69,7 +69,7 @@ { "wave": 8, "name": "関門 2", - "gate": "開発版の配布が書いた承認資料(issues/approval-foo-v1.2.0.md)を示して本番への配布の承認を取り、承認されたら `python3 /scripts/release-steps.py approve --approval issues/approval-foo-v1.2.0.md --approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" + "gate": "開発版の配布が書いた承認資料(/repo/issues/approval-foo-v1.2.0.md)を示して本番への配布の承認を取り、承認されたら `python3 /scripts/release-steps.py approve --approval /repo/issues/approval-foo-v1.2.0.md --approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" }, { "wave": 9, diff --git a/plugins/ndf/scripts/tests/test_project_mvv_normal.py b/plugins/ndf/scripts/tests/test_project_mvv_normal.py index eb2ce40b8..0a189be65 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv_normal.py +++ b/plugins/ndf/scripts/tests/test_project_mvv_normal.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import re from pathlib import Path from types import SimpleNamespace @@ -322,6 +323,8 @@ def test_normal_sprint_with_a_state_puts_the_advice_before_both_gates(tmp_path): assert "--what <要約> --by user --pr <設計 PR>" in waves["関門 1"]["gate"] and "--by user" in waves["関門 2"]["gate"] assert "note" not in waves["配布"] and "'関門 2' --what" in waves["関門 2"]["gate"] assert waves["関門 2"]["gate"].index("release-steps.py approve --approval") < waves["関門 2"]["gate"].index("「本番」のステージ") + # 承認資料は本番の release と同じく元のリポジトリの下の絶対パスで渡す(作業場所の git の根に依らない) + assert re.search(r"approve --approval /\S+/issues/approval-\S+\.md ", waves["関門 2"]["gate"]) assert "sprint-state.py gate" in out["next"] design = load(waves["設計"]["plans"][0]) ds = steps_of(design)