diff --git a/docs/adr/adr-027-push-review-simplicity-focus.md b/docs/adr/adr-027-push-review-simplicity-focus.md index fe157773..dae4e2a0 100644 --- a/docs/adr/adr-027-push-review-simplicity-focus.md +++ b/docs/adr/adr-027-push-review-simplicity-focus.md @@ -42,6 +42,18 @@ push 時点で見たかったのは「コードのシンプルさ」であり、 「architectural 妥当性 (cross-file, ADR 準拠, 命名規約)」→「コードのシンプルさ (diff 局所)」に責務を狭める。後者は diff だけで完結するため、reviewer が Grep/Read で探索する必要がなくなる。 +### Amendment (2026-07-21): 「diff 局所」は *観点* の限定であって *コミット範囲* の限定ではない + +本 ADR が狭めたのは **reviewer が使う criteria** (cross-file 探索を要求しない) であり、**レビュー対象に含めるコミットの範囲**ではない。この 2 つが混同され、`push-runner-config.toml` の `[diff] command` が `jj diff -r @` (tip コミットのみ) のまま運用された結果、祖先コミットが pre-push レビューを一度も経ずに merge される状態が生まれた (todo 順位 288、Severity High で PR #268/#300/#301/#311 と 4 回再発)。 + +レビュー対象は **PR 範囲 (`..@`) 全体**とする。根拠: + +- **速度は理由にならない (実測)**: 同一 PR に対しレビュー対象を 37 行 → 1011 行 (27 倍) に広げても所要時間は 4m32s → 4m43s の **+11 秒**。本 ADR の速度改善は arch-review facet の除去 (219-270s/iter) によるもので、diff 範囲の縮小は寄与していない。 +- **検知不能性**: レビュアーは渡された diff が PR 全体かを検証できないため、範囲が狭いことによる見落としは誰にも検知されない (実際 security-review が実 diff と矛盾して "docs-only / No dependency changes" と報告した)。 +- **CodeRabbit backstop では代替できない**: 第三者レビューは有用だが、セルフレビューを省いてよい理由にはならない。両者は独立した層として併用する。 + +実装は `Config::resolve_base_branch` に範囲解決を一本化し、`[diff] command` は `{{PR_RANGE}}` プレースホルダ経由で範囲を受け取る (config に revset を直書きできない)。加えて生成 diff が PR 範囲を網羅しているかを fail-closed で機械検査する。 + ### simplicity-review の criteria (diff 局所で完結) - ネスト深さ (>4 レベルで要改善) diff --git a/docs/todo-summary2.md b/docs/todo-summary2.md index 5347e8fa..e3ff2398 100644 --- a/docs/todo-summary2.md +++ b/docs/todo-summary2.md @@ -40,7 +40,6 @@ | 255 | 💎 Tier 3 | **ADR-040 の実測値を新 GPU (RTX PRO 5000 48GB) で再 calibration (ADR-046 WP-01 スパイクで陳腐化を観測)** | todo15.md | S | なし (ADR-038/040 が前提とする RTX 3070 8GB は RTX PRO 5000 Blackwell 48GB に更新済み。27-31B Q4 モデルが 100% GPU で動き VRAM が制約でなくなったため、ADR-040 の VRAM/latency trade-off 表と「VRAM scarcity → model swap 制約」framing が陳腐化。ADR-046 で mistral:7b / gemma4 / qwen3-coder の VRAM・latency を実測済 → ADR-040 amendment に反映、num_ctx 選定 flow の memory 軸を latency 軸へ再重み付け) | | 256 | ⏳ Tier 5 | **classifier FP 検出強化プロンプトで格上げ候補を再評価 (WP-04 見送りの follow-up、ADR-038 amendment 由来)** | todo15.md | M | なし (WP-04 実測で全候補が FP 検出未達 = 能力限界か `classify.txt` の mistral 向け tune 不適合かが未分離。FP 検出強化プロンプト版で qwen3-coder:30b 等を再測し、能力限界と確認できれば恒久見送り、プロンプト不適合なら該当モデル + 専用プロンプトで格上げ。eval 手法・gold セットは scratchpad WP-04 資産を再利用。materially better な新モデル出現時も再評価トリガー) | | 257 | ⏳ Tier 5 | **push pipeline の `cargo test` を cargo-nextest 化 (WP-05 で Stop hook には無効と判明、push 側 follow-up)** | todo15.md | S-M | なし (WP-05 実測: Stop hook は cargo test 不在で nextest 非適用、真因は逐次実行→並列化で解決済。ただし push pipeline (cli-push-runner quality_gate) の `cargo test -- --ignored` は実測 ~80s で nextest 高速化の余地あり。ツール依存追加 = ADR-017 pinning + 派生プロジェクト配布のコスト、push が Stop より低頻度な点を踏まえた費用対効果を評価。doctest は nextest 非実行のため `cargo test --doc` 併走が必要) | -| 264 | 🔧 Tier 2 | **pre-push review-diff.txt の生成形式を `jj diff --git` に切替 — LLM レビュアーの add/delete 誤読解消 (PR #256 post-merge-feedback Tier1 #1 採用)** | todo15.md | S | なし (`push-runner-config.toml:113` の `[diff] command = "jj diff -r @"` は色+行番号2列形式で、色を落とした review-diff.txt では削除が `-` マーカー無しになり LLM レビュアーが「追加」と誤読。PR #256 で todo 25行削除を simplicity-review が false positive REJECT し ~19分浪費。`jj diff --git -r @` へ切替で解消、`templates/push-runner-config.toml:52` も同 PR で修正必須 (deploy:hooks 配布)、Adoption Risk None、memory `prepush-review-diff-plain-format-misread.md`) | | 272 | 🚀 Tier 1 | **cli-docs-lint に ADR 重複採番 + CLAUDE.md 索引整合チェック追加 (PR #261 post-merge-feedback T1-#2 採用)** | todo15.md | S | なし (PR #261 で ADR-052/053 採番衝突が実発生、既存 cli-docs-lint の check-mode 骨格流用。順位 135 placeholder policy は todo entry 側の「ルール」で本 entry は land 済ファイルの「仕組み」検知、相補で重複ではない) | | 275 | 🔧 Tier 2 | **層別テストテンプレート (StubOllama パターン・integration 独立性) の共有化 (PR #265 post-merge-feedback T2-1 採用)** | todo15.md | M | なし (WP-11/ADR-054 の多層防御実装で「空 StubOllama による LLM 未呼び出し証明」「tempdir+jj init+CwdRestore の integration 独立性」を都度設計。WP-17 の classifier/scope guard 拡張で同種判断が再発見込み。shared crate 化の境界は ADR-044 で判定、WP-17 着手前の実施が効果的) | | 276 | 💎 Tier 3 | **ADR-007 に「コメント配置の意思決定フロー」を追加 (PR #265 post-merge-feedback T3-2 採用)** | todo15.md | S | なし (PR #265 で非 doc コメントの Bundle Z block が 2 回発生 = doc コメント/識別子名/マーカー付き Why の配置判断が未文書化。linter 自動化は NLP 必要で却下済み、既存 Q1-Q3 形式で人間/AI の判断補助を doc 化。バッチ PR で消化可) | @@ -52,7 +51,7 @@ | 285 | 🔧 Tier 2 | **jj keyword を含む commit message の tokenization edge-case テスト (PR #267 post-merge-feedback T2-2 採用)** | todo15.md | S | なし (順位 283 と表裏。283 の着手有無に関わらず現行挙動を regression test で固定する価値が独立して残る。283 と同一 PR 消化が効率的) | | 286 | 🔧 Tier 2 | **config path 解決の cwd 跨ぎ integration test (PR #267 post-merge-feedback T2-3 採用)** | todo15.md | M | なし (FIXED 済 cwd-config bug の regression guard。既存テストは pure parser のみで file-lookup 経路未カバー。Severity High、Adoption Risk = OS 依存) | | 287 | 💎 Tier 3 | **「config 読み hook は exe-relative 解決必須」convention の明文化 (PR #267 post-merge-feedback T3-1 採用)** | todo15.md | XS | なし (順位 281 の文書層補完。**281 と同一 PR bundle 推奨**、別作業に切り出す価値は低い) | -| 288 | 🚀 Tier 1 | **pre-push review が PR 全体をカバーしない — post-merge の全 run 集約 + push-runner `[diff]` stage の tip-only 範囲修正 (PR #268/#300/#301 feedback 採用、Severity High 3連続再発)** | todo15.md | M | なし (根因は `[diff]` stage `jj diff -r @` が tip のみ = 祖先 code が AI レビュー未経由で merge。`docs_only_routing` は PR 範囲へ修正済だが `[diff]` は非対称。単一 push では全 run 集約でも救えず [diff] 範囲拡張 + bookmark_check 祖先検証が根治。ADR-027 射程はユーザー判断。独立 PR 推奨) | +| 288 | 🚀 Tier 1 | **pre-push review が PR 全体をカバーしない — post-merge の全 run 集約 + push-runner `[diff]` stage の tip-only 範囲修正 (PR #268/#300/#301 feedback 採用、Severity High 3連続再発)** | todo15.md | M | なし (【一部実装済 2026-07-21】`[diff]` stage の PR 範囲化 + 範囲カバレッジ検査 (fail-closed) + top-level default_branch 一本化 + `--git` 切替 (順位 264 同時完了) を実施。ADR-027 射程は実測 (37行 4m32s → 1011行 4m43s = +11秒) を根拠に範囲拡張を採用。**残**: post-merge feedback の全 run 集約と bookmark_check.rs の祖先未レビュー穴の検証) | | 292 | 🔧 Tier 2 | **cli-pr-monitor の lock.rs を token 方式の所有権検証へ統一** | todo15.md | S-M | なし (PR #271 で pipeline_lock.rs に導入した token ベース所有権検証と同型の Drop 無条件削除バグが cli-pr-monitor/src/lock.rs にも残存。参照実装が既にあるため低リスク) | | 293 | ⏳ Tier 5 | **push-runner の stack push モード (opt-in、YAGNI につき見送り継続)** | todo15.md | M | なし (stacked bookmark 運用の実績が現状なく、必要になった時点で着手する opt-in 拡張として記録のみ) | | 294 | 💎 Tier 3 | **jj-op-verify hook の位置づけ再整理 — 並列 workspace 安全化ではなく混線緩和層として再分類** | todo15.md | S | なし (検知対象は出力混線の症状であり並列 workspace とは独立に価値を持つ。ADR-045→ADR-053 の枠組みへ紐付け直すドキュメント再整理のみ) | diff --git a/docs/todo15.md b/docs/todo15.md index 1a492393..11926481 100644 --- a/docs/todo15.md +++ b/docs/todo15.md @@ -218,39 +218,6 @@ --- -### pre-push review-diff.txt の生成形式を jj diff --git に切替 — LLM レビュアーの add/delete 誤読解消 (PR #256 post-merge-feedback Tier1 #1 採用) - -> **動機**: `push-runner-config.toml:113` の `[diff] command = "jj diff -r @"`(jj デフォルト形式)で生成される `.takt/review-diff.txt` は、追加/削除を色 + 行番号2列(`NNN :` = 削除 / ` NNN:` = 追加)で表現する。ファイル化で色が落ちると `-`/`+` マーカーが無くなり、削除が「左列のみ行番号」でしか区別できず、pre-push の LLM レビュアー(simplicity-review 等)が削除ブロックを「追加」と誤読しうる。`--git`(標準 unified diff)は色非依存で `+`/`-` を明示するため誤読しない。PR #256(ADR-051 起票 PR)で todo エントリ25行の**削除**を simplicity-review が「追加」と誤読し stale-tracking-entry として false positive REJECT を出し、レビュー約19分を浪費した実害が発生した。 -> -> **本タスクの位置づけ**: PR #256 post-merge-feedback Tier1 #1 で採用(他6提案は over-engineering として却下)。fix ステップの「hunk-polarity bug」という診断は不正確で、真因は色を落とした平文 diff の LLM 可読性問題。 -> -> **参照**: `push-runner-config.toml:113`(`command = "jj diff -r @"` → `"jj diff --git -r @"`、修正対象)、`templates/push-runner-config.toml:52`(同様の変更、`pnpm deploy:hooks` で派生プロジェクトに配布されるため**両方修正必須**)、memory `prepush-review-diff-plain-format-misread.md`、PR #256 feedback report (`.claude/feedback-reports/256.md`) Tier1 #1 -> -> **実行優先度**: 🔧 Tier 2 — Effort S。false positive で約19分浪費した実害が既に発生しており、config + template 各1箇所の軽微な修正で再発を防止できる。 - -#### 設計決定 (案) - -- `[diff] command` を `jj diff --git -r @` に変更。本番 config と template の2箇所を同一 PR で修正(template 未修正だと派生プロジェクトに同じ false positive が横展開)。 -- review-diff.txt を format-sensitive に parse する `.rs` 箇所は存在せず(LLM facet が読むのみ)、Adoption Risk None。 - -#### 作業計画 - -- [ ] `push-runner-config.toml:113` を `command = "jj diff --git -r @"` に変更 -- [ ] `templates/push-runner-config.toml:52` も同様に変更 -- [ ] review-diff.txt を参照する箇所(facet instruction / `.rs`)が `--git` 形式で問題ないか確認 -- [ ] dogfood: 削除を含む diff で pre-push review が正しく削除を認識することを確認 -- [ ] 本エントリ削除 + todo-summary2.md 行削除 - -#### 完了基準 - -- pre-push review が削除ブロックを「追加」と誤読しなくなり、config + template 両方が `--git` 形式、派生プロジェクトへの横展開も解消。 - -#### 詰まっている箇所 - -- なし(変更箇所・影響範囲とも確定済み、PR #256 feedback report で cross-validation 済み)。 - ---- - ### cli-docs-lint に ADR 重複採番 + CLAUDE.md 索引整合チェック追加 (PR #261 post-merge-feedback T1-#2 採用) > **動機**: PR #261 で当方が ADR-052 として起草した ADR が、並行 land した PR #260 の ADR-052 (自律実行境界) と採番衝突し、rebase 時にファイル名 + 本文タイトル + ソース内参照 10+ 箇所の置換が発生した実例。ADR は既に 53 件、並行 PR 開発が常態化しており再発頻度 Medium。現状この衝突を機械検知する層が存在しない (発見は rebase 時の CLAUDE.md conflict 頼み)。 @@ -477,13 +444,17 @@ > > **再発・優先度見直し (2026-07-19、#300/#301 feedback)**: PR-N2 (#300) / PR-N3 (#301) の feedback で同型 gap が Severity High で再発。根因は本エントリの集約範囲より上流の **push-runner `[diff]` stage** (`push-runner-config.toml` の `[diff] command = "jj diff -r @"`) が **tip コミットのみ**を AI レビュー用 diff に書き出す点。複数コミットを 1 回の `pnpm push` でまとめて送ると、tip 以外の祖先コミット (例 #300 の `resolve_main_workspace_root()` 実装、#301 の `Cargo.toml`/`main.rs` 変更) が local security/simplicity レビューを一度も経ずに merge される (security-review.md が実 diff と矛盾して "docs-only / No dependency changes" と記載)。**単一 push では pre-push run 自体が 1 回のみ**のため、本エントリの「全 run 集約」だけでは救えない。よって本エントリの実装時に、(a) `[diff]` stage の diff 範囲を `docs_only_routing` と同様に `..@` (PR 範囲) へ拡張し、(b) `bookmark_check.rs` の `@` 非 trunk 祖先が未レビューのまま push される穴 (T8 / PR #280 と同クラス) の検証を併せて行う。`docs_only_routing.rs` の skip 判定は既に `..@` に修正済みだが `[diff]` stage 自体は未修正で非対称。ADR-027 (push-time review を diff-local に限定し範囲外は CodeRabbit backstop) の trade-off 射程が security-review にも及ぶかはユーザー判断待ち。 > +> **(a) 実装済 (2026-07-21)**: PR #311 で 4 回目の再発 (695 行の PR に対しレビュー対象 37 行、security-review が実 diff と矛盾して "docs-only / No dependency changes" と記載) を観測し、`[diff]` stage を修正した。top-level `default_branch` を新設して `diff` / `docs_only_routing` / `pr_size_check` の 3 stage が同一解決を共有し、`[diff] command` は `{{PR_RANGE}}` プレースホルダ経由で範囲を受け取る (config に revset を直書きできない)。加えて生成 diff が PR 範囲の全変更ファイルを含むかを `jj diff --summary` と突き合わせる**範囲カバレッジ検査**を fail-closed で追加し、config の書き方に依存せず「レビュー範囲 < PR 範囲」を検知できるようにした (未更新の派生プロジェクト config も捕まる)。順位 264 (`--git` 形式切替) も同 PR で同時実施 (範囲検査が `diff --git` ヘッダを読む要件と重なるため)。**ADR-027 の射程についてのユーザー判断**: 範囲拡張のレビュー時間コストを実測したところ 37 行 4m32s → 1011 行 4m43s (**+11 秒**) で、ADR-027 の速度改善は arch-review facet 除去によるものであり diff 範囲縮小は寄与していないことが判明したため、範囲拡張を採用した。 +> +> **残タスク**: 本エントリ本体の「post-merge feedback の全 run 集約」と、上記 (b) `bookmark_check.rs` の祖先未レビュー穴の検証は未着手。 +> > **参照**: `.claude/feedback-reports/268.md` Tier 2 #1 / `.claude/feedback-reports/300.md` Tier1 #1 / `.claude/feedback-reports/301.md` Tier1 #1、`src/cli-merge-pipeline/src/feedback/context.rs` (`find_latest_prepush_reports_dir`)、`push-runner-config.toml` (`[diff]` section)、`src/cli-push-runner/src/stages/diff.rs`・`src/cli-push-runner/src/stages/bookmark_check.rs`、`src/cli-push-runner/src/config/docs_only_routing.rs` (既に PR 範囲へ修正済の対照)、`.takt/facets/instructions/analyze-prepush-reports.md`、[ADR-027](adr/adr-027-push-review-simplicity-focus.md) > > **実行優先度**: 🚀 Tier 1 — Severity High (review gate の silent 覆域縮小が 3 PR 連続で再発) / Frequency Medium (複数コミットを 1 push する運用で恒常発生) / Effort M。context スキーマ変更 + facet 更新 + `[diff]` stage 修正 + テストを伴うため独立 PR 推奨 (旧 Tier 2 から昇格)。 #### 作業計画 -- [ ] **`[diff]` stage の diff 範囲を `..@` (PR 範囲) に拡張** — 祖先コミットの code 変更も AI レビュー用 diff に含める (`docs_only_routing` の skip 判定と同基準に揃える)。 +- [x] **`[diff]` stage の diff 範囲を `..@` (PR 範囲) に拡張** — 祖先コミットの code 変更も AI レビュー用 diff に含める (`docs_only_routing` の skip 判定と同基準に揃える)。**PR #311/#313 で実装済** (上記 (a) 参照。範囲カバレッジ検査 + config-load 時の `{{PR_RANGE}}` 必須検証込み)。 - [ ] `bookmark_check.rs` で `@` 非 trunk 祖先が未レビューのまま push される穴を検証・塞ぐ (T8 / PR #280 と同クラス)。 - [ ] 対象 PR の pre-push run dir を列挙する関数に拡張。時刻範囲のみでの絞り込みは対象外 run の混入・対象 run の欠落を招くため、対象 PR のコミット範囲や関連 bookmark 名など複数の識別根拠を突き合わせて対象 run を判定すること (`.takt/runs/*-pre-push-review`) - [ ] context json の `prepush_reports_dir` を配列化 + facet instruction を複数 dir 対応に (スキーマ契約変更のため: 全 reader の列挙 + 旧 string 形式との後方互換 or schema versioning + 空配列時の挙動を明記) diff --git a/push-runner-config.toml b/push-runner-config.toml index cf489de6..0f729469 100644 --- a/push-runner-config.toml +++ b/push-runner-config.toml @@ -3,6 +3,18 @@ # pnpm push で起動される push-runner.exe がこのファイルを読み込む。 # カレントディレクトリ (リポジトリルート) を優先的に検索する。 +# PR base branch。`diff` / `docs_only_routing` / `pr_size_check` の 3 stage が +# 「PR 範囲」= `..@` をここから解決する (唯一の真実源)。 +# 各 section にも `default_branch` を書けるが後方互換のための override であり、 +# 新規に section 側で持たせないこと (値が食い違うと stage 間で見る範囲がずれる)。 +# +# TOML の table 構文上、本キーは**最初の [section] より前**に置く必要がある。 +# section の後ろに書くとその section のキーとして解釈され、top-level には効かない。 +# +# 未設定なら "master" (DEFAULT_BASE_BRANCH)。main 系リポジトリでは +# `default_branch = "main"` を有効化すること。 +# default_branch = "master" + # --------------------------------------------------------------------------- # [scratch_file_warning] — 順位 1 (PR #85 T1-4): scratch ファイル混入の防御層。 # `@` commit に `__*` 等の scratch ファイルが含まれていないか push 前に検査し、 @@ -152,7 +164,24 @@ commands = [ ] [diff] -command = "jj diff -r @" +# `{{PR_RANGE}}` は push-runner が `..@` に展開する。 +# +# **exe と config は同時に更新すること** (ADR-051 cross-system config coupling): +# プレースホルダ対応前の cli-push-runner.exe が本 config を読むと `{{PR_RANGE}}` が +# そのまま jj に渡り `Failed to parse revset: Syntax error` で exit 5 になる +# (2026-07-21 実観測)。fail-closed なので silent 誤動作にはならないが、 +# `pnpm build:cli-push-runner` (or `build:all`) で exe を更新すれば解消する。 +# **revset を直書きしないこと**: `-r @` のように PR より狭い範囲を書くと、祖先 +# コミットが AI レビューを一度も経ずに merge される (todo 順位 288、Severity High で +# PR #268/#300/#301/#311 と 4 回再発)。レビュアー側からは渡された diff が PR 全体か +# 検証できないため、誤りは誰にも検知されない。 +# +# `--git` (unified diff) 必須。(a) 範囲カバレッジ検査が `diff --git` ヘッダから +# ファイル一覧を抽出する、(b) jj 既定形式は色を落とすと `+`/`-` が消え LLM +# レビュアーが削除を追加と誤読する (todo 順位 264、PR #256 で約 19 分浪費)。 +# +# 生成された diff が PR 範囲を網羅していない場合は exit 5 で中断する (fail-closed)。 +command = "jj diff --git -r {{PR_RANGE}}" output_path = ".takt/review-diff.txt" # timeout (T6): 未指定時は 60s (DEFAULT_DIFF_TIMEOUT_SECS)。旧実装は無限待ちで、 # ADR-045 の並列 workspace 運用で jj lock 競合が起きるとパイプラインが無言ハングした。 diff --git a/src/cli-push-runner/src/config/docs_only_routing.rs b/src/cli-push-runner/src/config/docs_only_routing.rs index a7466771..9dde0c15 100644 --- a/src/cli-push-runner/src/config/docs_only_routing.rs +++ b/src/cli-push-runner/src/config/docs_only_routing.rs @@ -1,13 +1,5 @@ use serde::Deserialize; -/// docs-only routing の base branch 既定値。PR 範囲 `format!("{}..@", branch)` の base。 -/// -/// `[pr_size_check]` の `default_branch` と**論理的に同一の値でなければならない** -/// (両者とも「PR の base branch」を指す)。config で別々に持つのは ADR-039 の -/// section 独立性のためだが、値が食い違うと一方が誤った範囲を見る (ADR-051 の -/// cross-config coupling)。`push-runner-config.toml` のコメントに同期義務を明記する。 -pub(crate) const DEFAULT_DOCS_ONLY_BASE_BRANCH: &str = "master"; - /// docs-only と判定されたとき skip する quality_gate group の既定値。 /// /// `rust-lint-test` は diff が docs-only (ADR-035 path 基準) のとき結果が変わり得ない @@ -49,14 +41,6 @@ pub(crate) struct DocsOnlyRoutingConfig { } impl DocsOnlyRoutingConfig { - pub(crate) fn effective_default_branch(&self) -> String { - self.default_branch - .as_ref() - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_DOCS_ONLY_BASE_BRANCH.to_string()) - } - pub(crate) fn effective_skip_groups(&self) -> Vec { match &self.skip_groups { Some(groups) if !groups.is_empty() => groups.clone(), @@ -94,11 +78,15 @@ command = "echo push" BASE ); let config = parse(&toml_str); + assert_eq!( + config.docs_only_pr_range(), + "main..@", + "section の default_branch は top-level より優先される (後方互換)" + ); let s = config .docs_only_routing .expect("[docs_only_routing] should parse to Some"); assert_eq!(s.enabled, Some(true)); - assert_eq!(s.effective_default_branch(), "main"); assert_eq!(s.effective_skip_groups(), vec!["rust-lint-test", "heavy"]); } @@ -115,9 +103,13 @@ command = "echo push" fn effective_defaults_when_fields_omitted() { let toml_str = format!("{}\n[docs_only_routing]\nenabled = true\n", BASE); let config = parse(&toml_str); + assert_eq!( + config.docs_only_pr_range(), + format!("{}..@", crate::config::DEFAULT_BASE_BRANCH), + "section / top-level とも未設定なら共通の既定 base branch に倒れる" + ); let s = config.docs_only_routing.unwrap(); assert_eq!(s.enabled, Some(true)); - assert_eq!(s.effective_default_branch(), "master"); assert_eq!(s.effective_skip_groups(), vec!["rust-lint-test"]); } diff --git a/src/cli-push-runner/src/config/lint_screen.rs b/src/cli-push-runner/src/config/lint_screen.rs index 6fce7037..c0c2296d 100644 --- a/src/cli-push-runner/src/config/lint_screen.rs +++ b/src/cli-push-runner/src/config/lint_screen.rs @@ -335,6 +335,7 @@ command = "echo push" fn make_config_without_lint_screen() -> Config { Config { + default_branch: None, quality_gate: QualityGateConfig { parallel: None, step_timeout: None, diff --git a/src/cli-push-runner/src/config/mod.rs b/src/cli-push-runner/src/config/mod.rs index d8d0a556..20de7073 100644 --- a/src/cli-push-runner/src/config/mod.rs +++ b/src/cli-push-runner/src/config/mod.rs @@ -15,7 +15,7 @@ pub(crate) use lint_screen::{ DEFAULT_LINT_SCREEN_TIMEOUT_SECS, }; pub(crate) use pr_size_check::{ - PrSizeCheckConfig, DEFAULT_PR_SIZE_BASE_BRANCH, DEFAULT_PR_SIZE_BLOCK_THRESHOLD, + PrSizeCheckConfig, DEFAULT_PR_SIZE_BLOCK_THRESHOLD, DEFAULT_PR_SIZE_WARNING_THRESHOLD, }; pub(crate) use scratch_file_warning::ScratchFileWarningConfig; @@ -34,8 +34,29 @@ pub(crate) const DEFAULT_PUSH_TIMEOUT_SECS: u64 = 300; /// 余裕側に倒す。詰まる環境では `[diff] timeout` で上書きする。 pub(crate) const DEFAULT_DIFF_TIMEOUT_SECS: u64 = 60; +/// PR base branch の既定値。 +/// +/// 「PR 範囲」= `format!("{}..@", base_branch)` を組み立てる全 stage +/// (`diff` / `docs_only_routing` / `pr_size_check`) がこの 1 箇所を参照する。 +/// 以前は section ごとに `default_branch` を持ち「値を同期する義務」を config の +/// コメントで課していたが、義務はコード上の不変条件ではないため非対称を許した +/// (実際に `[diff]` だけ PR 範囲を見ておらず、祖先コミットが AI レビュー未経由で +/// merge される欠陥が 4 回再発した。todo 順位 288 / ADR-051 cross-config coupling)。 +pub(crate) const DEFAULT_BASE_BRANCH: &str = "master"; + +/// branch 名を trim し、空文字を `None` に落とす (空白のみの設定値を未設定扱いにする)。 +fn normalize_branch(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + #[derive(Deserialize)] pub(crate) struct Config { + /// 全 stage 共通の PR base branch。section 側の `default_branch` が + /// 設定されていればそちらが優先される (後方互換のための override)。 + pub(crate) default_branch: Option, pub(crate) quality_gate: QualityGateConfig, pub(crate) diff: Option, pub(crate) lint_screen: Option, @@ -48,6 +69,83 @@ pub(crate) struct Config { pub(crate) post_takt_regate: Option, } +impl Config { + /// PR base branch を解決する。優先順: + /// section override → [`Config::effective_default_branch`] (top-level か、 + /// 全一致する section override 群) → 既定値。 + /// + /// section override は後方互換のために残してある (既存の派生プロジェクト config が + /// `[pr_size_check] default_branch` 等を持つため)。新規に section 側で持たせないこと。 + /// override 値が stage 間で食い違わないことは [`validate_base_branch_ranges_agree`] + /// が `load_config` 時に fail-closed で保証する。 + pub(crate) fn resolve_base_branch(&self, section_override: Option<&str>) -> String { + normalize_branch(section_override) + .or_else(|| self.effective_default_branch()) + .unwrap_or_else(|| DEFAULT_BASE_BRANCH.to_string()) + } + + /// config 全体で共有する effective base branch を返す (CodeRabbit #313)。 + /// + /// top-level `default_branch` が明示されていればそれ。無ければ section override 群が + /// **全て一致**していればその値を全 stage 共通の base とする。top-level を書かず + /// section override だけで base を揃えた legacy config で、override 未設定の stage + /// (例 `[diff]`) が `DEFAULT_BASE_BRANCH` に落ちる理由だけで + /// [`validate_base_branch_ranges_agree`] に reject されるのを防ぐ。override 群が + /// 食い違う場合は `None` を返し、各 stage が自分の override or 既定に解決した結果を + /// validate が fail-closed で検知する (genuine な不一致は従来どおり拒否)。 + fn effective_default_branch(&self) -> Option { + if let Some(top) = normalize_branch(self.default_branch.as_deref()) { + return Some(top); + } + let overrides: Vec = [ + self.diff.as_ref().and_then(|c| c.default_branch.as_deref()), + self.docs_only_routing + .as_ref() + .and_then(|c| c.default_branch.as_deref()), + self.pr_size_check + .as_ref() + .and_then(|c| c.default_branch.as_deref()), + ] + .into_iter() + .filter_map(normalize_branch) + .collect(); + let first = overrides.first()?; + overrides.iter().all(|v| v == first).then(|| first.clone()) + } + + /// PR 範囲の revset (`..@`) を組み立てる。 + /// + /// revset literal を各所に散らさないための唯一の組立点 + /// (rule⑫ `no-hardcoded-jj-revset-range` の趣旨を config 側にも適用する)。 + pub(crate) fn pr_range_revset(&self, section_override: Option<&str>) -> String { + format!("{}..@", self.resolve_base_branch(section_override)) + } + + /// AI レビュー対象 diff の PR 範囲。 + pub(crate) fn diff_pr_range(&self) -> String { + self.pr_range_revset(self.diff.as_ref().and_then(|c| c.default_branch.as_deref())) + } + + /// docs-only routing 判定の PR 範囲。[`Config::diff_pr_range`] と一致していなければ + /// 「レビューした範囲」と「routing を決めた範囲」がずれる。 + pub(crate) fn docs_only_pr_range(&self) -> String { + self.pr_range_revset( + self.docs_only_routing + .as_ref() + .and_then(|c| c.default_branch.as_deref()), + ) + } + + /// PR size 計測の PR 範囲。 + pub(crate) fn pr_size_pr_range(&self) -> String { + self.pr_range_revset( + self.pr_size_check + .as_ref() + .and_then(|c| c.default_branch.as_deref()), + ) + } +} + #[derive(Deserialize)] pub(crate) struct QualityGateConfig { pub(crate) parallel: Option, @@ -82,12 +180,27 @@ pub(crate) struct PrePushReviewConfig { pub(crate) refute_workflow: Option, } +/// `[diff] command` 内で PR 範囲 revset に展開されるプレースホルダ。 +/// +/// config に revset を直書きさせないための間接層。直書きを許すと +/// 「`-r @` (tip のみ)」のような **PR より狭い範囲**を書けてしまい、 +/// 祖先コミットが AI レビューを一度も経ずに merge される (todo 順位 288、4 回再発)。 +pub(crate) const DIFF_PR_RANGE_PLACEHOLDER: &str = "{{PR_RANGE}}"; + #[derive(Deserialize)] pub(crate) struct DiffConfig { + /// diff 生成コマンド。[`DIFF_PR_RANGE_PLACEHOLDER`] が PR 範囲 revset に展開される。 + /// + /// 出力は **unified diff (`--git` 形式)** である必要がある。範囲カバレッジ検査が + /// `diff --git a/… b/…` ヘッダからファイル一覧を抽出するため。jj の既定形式は + /// `+`/`-` マーカーを持たず LLM レビュアーが削除を追加と誤読する問題もある + /// (todo 順位 264、PR #256 で実害)。 pub(crate) command: String, pub(crate) output_path: String, /// 未指定時は `DEFAULT_DIFF_TIMEOUT_SECS` (T6)。`[push] timeout` と同形。 pub(crate) timeout: Option, + /// PR base branch の section override (後方互換)。未設定なら top-level を使う。 + pub(crate) default_branch: Option, } #[derive(Deserialize)] @@ -157,344 +270,59 @@ fn validate_config(config: &Config) -> Result<(), String> { )); } } + validate_diff_command_uses_pr_range(config)?; + validate_base_branch_ranges_agree(config)?; Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_parses_full_without_diff() { - let toml_str = r#" -[quality_gate] -parallel = true -step_timeout = 60 - -[[quality_gate.groups]] -name = "lint" -commands = ["pnpm lint"] - -[[quality_gate.groups]] -name = "test" -pre = "pnpm install" -commands = ["pnpm test", "pnpm test:e2e"] - -[takt] -workflow = "pre-push-review" -task = "pre-push review" -extra_args = ["--pipeline", "--skip-git"] - -[push] -command = "jj git push" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - - assert_eq!(config.quality_gate.parallel, Some(true)); - assert_eq!(config.quality_gate.step_timeout, Some(60)); - assert_eq!(config.quality_gate.groups.len(), 2); - assert!(config.diff.is_none()); - - assert_eq!(config.takt.workflow, "pre-push-review"); - assert_eq!(config.takt.task, "pre-push review"); - assert_eq!(config.takt.extra_args.as_ref().unwrap().len(), 2); - - assert_eq!(config.push.command, "jj git push"); - assert!(config.push.timeout.is_none()); - } - - #[test] - fn config_push_timeout_explicit() { - let toml_str = r#" -[quality_gate] -[[quality_gate.groups]] -name = "test" -commands = ["echo ok"] - -[takt] -workflow = "w" -task = "t" - -[push] -command = "jj git push" -timeout = 600 -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.push.timeout, Some(600)); - assert_eq!( - config.push.timeout.unwrap_or(DEFAULT_PUSH_TIMEOUT_SECS), - 600, - ); - } - - #[test] - fn config_push_timeout_defaults() { - let toml_str = r#" -[quality_gate] -[[quality_gate.groups]] -name = "test" -commands = ["echo ok"] - -[takt] -workflow = "w" -task = "t" - -[push] -command = "echo push" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert!(config.push.timeout.is_none()); - assert_eq!( - config.push.timeout.unwrap_or(DEFAULT_PUSH_TIMEOUT_SECS), - DEFAULT_PUSH_TIMEOUT_SECS, - ); - } - - #[test] - fn config_parses_with_diff() { - let toml_str = r#" -[quality_gate] -[[quality_gate.groups]] -name = "test" -commands = ["echo ok"] - -[diff] -command = "jj diff -r @" -output_path = ".takt/review-diff.txt" - -[takt] -workflow = "pre-push-review" -task = "pre-push review" - -[push] -command = "jj git push" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - - let diff = config.diff.unwrap(); - assert_eq!(diff.command, "jj diff -r @"); - assert_eq!(diff.output_path, ".takt/review-diff.txt"); - assert!(diff.timeout.is_none()); - } - - /// T6: `[diff] timeout` 未指定時は既定値に落ちる (本リポジトリの config は未指定)。 - #[test] - fn config_diff_timeout_defaults() { - let toml_str = r#" -[quality_gate] -[[quality_gate.groups]] -name = "test" -commands = ["echo ok"] - -[diff] -command = "jj diff -r @" -output_path = ".takt/review-diff.txt" - -[takt] -workflow = "w" -task = "t" - -[push] -command = "echo push" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - let diff = config.diff.unwrap(); - assert!(diff.timeout.is_none()); - assert_eq!( - diff.timeout.unwrap_or(DEFAULT_DIFF_TIMEOUT_SECS), - DEFAULT_DIFF_TIMEOUT_SECS, - ); - } - - /// T6: 大 diff / 低速環境向けの escape hatch (既定 60s では足りない場合)。 - #[test] - fn config_diff_timeout_explicit() { - let toml_str = r#" -[quality_gate] -[[quality_gate.groups]] -name = "test" -commands = ["echo ok"] - -[diff] -command = "jj diff -r @" -output_path = ".takt/review-diff.txt" -timeout = 180 - -[takt] -workflow = "w" -task = "t" - -[push] -command = "echo push" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert_eq!(config.diff.unwrap().timeout, Some(180)); - } - - #[test] - fn config_quality_gate_defaults() { - let toml_str = r#" -[quality_gate] - -[[quality_gate.groups]] -name = "test" -commands = ["echo ok"] - -[takt] -workflow = "w" -task = "t" - -[push] -command = "echo push" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert!(config.quality_gate.parallel.unwrap_or(true)); - assert_eq!( - config - .quality_gate - .step_timeout - .unwrap_or(DEFAULT_STEP_TIMEOUT_SECS), - DEFAULT_STEP_TIMEOUT_SECS, - ); - assert!(config.takt.extra_args.is_none()); - } - - #[test] - fn config_pre_field_optional() { - let toml_str = r#" -[quality_gate] -[[quality_gate.groups]] -name = "no-pre" -commands = ["echo test"] - -[[quality_gate.groups]] -name = "with-pre" -pre = "echo install" -commands = ["echo test"] - -[takt] -workflow = "w" -task = "t" - -[push] -command = "echo push" -"#; - let config: Config = toml::from_str(toml_str).unwrap(); - assert!(config.quality_gate.groups[0].pre.is_none()); - assert!(config.quality_gate.groups[1].pre.is_some()); - } - - #[test] - fn validate_rejects_empty_groups() { - let config = Config { - quality_gate: QualityGateConfig { - parallel: None, - step_timeout: None, - groups: vec![], - }, - diff: None, - lint_screen: None, - scratch_file_warning: None, - pr_size_check: None, - pre_push_review: None, - docs_only_routing: None, - post_takt_regate: None, - takt: TaktConfig { - workflow: "w".into(), - task: "t".into(), - extra_args: None, - }, - push: PushConfig { - command: "echo".into(), - timeout: None, - }, - }; - let result = validate_config(&config); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("groups が空")); - } - - #[test] - fn validate_rejects_empty_commands() { - let config = Config { - quality_gate: QualityGateConfig { - parallel: None, - step_timeout: None, - groups: vec![GroupConfig { - name: "empty".into(), - pre: None, - commands: vec![], - }], - }, - diff: None, - lint_screen: None, - scratch_file_warning: None, - pr_size_check: None, - pre_push_review: None, - docs_only_routing: None, - post_takt_regate: None, - takt: TaktConfig { - workflow: "w".into(), - task: "t".into(), - extra_args: None, - }, - push: PushConfig { - command: "echo".into(), - timeout: None, - }, - }; - let result = validate_config(&config); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("'empty'")); - } - - /// resolve_takt_workflow テスト用に base config + 任意の [pre_push_review] - /// section を組み立てる。base workflow は "pre-push-review"。 - fn config_with_optional_pre_push(pre_push_section: &str) -> Config { - let toml_str = format!( - r#" -[quality_gate] -[[quality_gate.groups]] -name = "test" -commands = ["echo ok"] - -[takt] -workflow = "pre-push-review" -task = "pre-push review" - -[push] -command = "echo push" -{pre_push_section} -"# - ); - toml::from_str(&toml_str).unwrap() - } - - #[test] - fn resolve_workflow_base_when_section_absent() { - let config = config_with_optional_pre_push(""); - assert_eq!(resolve_takt_workflow(&config), "pre-push-review"); - } - - #[test] - fn resolve_workflow_base_when_refute_disabled() { - let config = config_with_optional_pre_push( - "[pre_push_review]\nrefute_enabled = false\nrefute_workflow = \"pre-push-review-refute\"", - ); - assert_eq!(resolve_takt_workflow(&config), "pre-push-review"); - } - - #[test] - fn resolve_workflow_refute_when_enabled() { - let config = config_with_optional_pre_push( - "[pre_push_review]\nrefute_enabled = true\nrefute_workflow = \"pre-push-review-refute\"", - ); - assert_eq!(resolve_takt_workflow(&config), "pre-push-review-refute"); +/// `[diff] command` が [`DIFF_PR_RANGE_PLACEHOLDER`] を含むことを config-load 時に検証する +/// (CodeRabbit #313)。 +/// +/// プレースホルダを欠いた command (例: legacy の `jj diff -r @`) は PR 範囲を無視して +/// tip のみをレビュー対象にし得る。範囲カバレッジ検査 (`stages::diff::verify_diff_covers_pr_range`) +/// が runtime の backstop として残るが、config-load 時に明示エラーで弾くことで +/// 「なぜ diff が狭い/空なのか」を実行時ではなく設定時点で判る診断に前倒しする +/// (fail-closed / ADR-043、defense-in-depth)。`[diff]` section 不在時は無検査。 +fn validate_diff_command_uses_pr_range(config: &Config) -> Result<(), String> { + let Some(diff) = &config.diff else { + return Ok(()); + }; + if diff.command.contains(DIFF_PR_RANGE_PLACEHOLDER) { + return Ok(()); } + Err(format!( + "設定ファイルエラー: [diff] command が {placeholder} を含みません (現在: {command:?})。\ + PR 範囲全体をレビュー対象にするため command には {placeholder} を使うこと \ + (例: \"jj diff --git -r {placeholder}\")。直書きの範囲 (`-r @` 等) は tip のみを\ + レビューし、祖先コミットが未レビューで merge される (todo 順位 288、4 回再発)", + placeholder = DIFF_PR_RANGE_PLACEHOLDER, + command = diff.command, + )) +} - #[test] - fn resolve_workflow_base_when_enabled_but_no_refute_workflow() { - let config = config_with_optional_pre_push("[pre_push_review]\nrefute_enabled = true"); - assert_eq!(resolve_takt_workflow(&config), "pre-push-review"); +/// 3 stage (`diff` / `docs_only_routing` / `pr_size_check`) が解決する PR 範囲が +/// 一致することを検査する (SIM-NEW-config-mod-rs-L69)。 +/// +/// section 側の `default_branch` override は後方互換のために残しているが、 +/// 各 stage が独立解決するため、override が top-level や他 section の値と +/// 食い違うと `[diff]` だけが狭い範囲を見る非対称が復活する (todo 順位 288、 +/// 4 回再発)。「値を同期する義務」を config のコメントだけに頼らず、 +/// コード上の不変条件として fail-closed で強制する。 +fn validate_base_branch_ranges_agree(config: &Config) -> Result<(), String> { + let ranges = [ + ("diff", config.diff_pr_range()), + ("docs_only_routing", config.docs_only_pr_range()), + ("pr_size_check", config.pr_size_pr_range()), + ]; + let (_, diff_range) = &ranges[0]; + if let Some((name, range)) = ranges.iter().find(|(_, range)| range != diff_range) { + return Err(format!( + "設定ファイルエラー: PR 範囲が stage 間で一致しません ([{name}] は \"{range}\" に解決、\ + [diff] は \"{diff_range}\" に解決)。default_branch は top-level か、全 section で同じ値にすること" + )); } + Ok(()) } + +#[cfg(test)] +mod tests; diff --git a/src/cli-push-runner/src/config/pr_size_check.rs b/src/cli-push-runner/src/config/pr_size_check.rs index 4c73885e..d6463af9 100644 --- a/src/cli-push-runner/src/config/pr_size_check.rs +++ b/src/cli-push-runner/src/config/pr_size_check.rs @@ -9,10 +9,6 @@ pub(crate) const DEFAULT_PR_SIZE_WARNING_THRESHOLD: usize = 800; /// 大型 refactoring 時は config / env override で意図的バイパス。 pub(crate) const DEFAULT_PR_SIZE_BLOCK_THRESHOLD: usize = 1500; -/// 順位 151: PR base の default branch 名。`format!("{}..@", default_branch)` で -/// revset 組立 (rule⑫ `no-hardcoded-jj-revset-range` 適用)。 -pub(crate) const DEFAULT_PR_SIZE_BASE_BRANCH: &str = "master"; - /// 順位 151 (Bundle "既存ルール仕組み化") — PR diff size を `jj diff --stat` で計測し /// warning / block する pre-push stage の config。 /// diff --git a/src/cli-push-runner/src/config/tests.rs b/src/cli-push-runner/src/config/tests.rs new file mode 100644 index 00000000..3683b8b5 --- /dev/null +++ b/src/cli-push-runner/src/config/tests.rs @@ -0,0 +1,549 @@ +//! config のテスト (production は ./mod.rs)。ファイル長 800 行ガイドライン +//! (順位 147) 遵守のため test mod を切り出した。 + +use super::*; + +/// base branch 解決の 3 段 (section override → top-level → 既定値) と、 +/// **3 stage が同じ範囲に解決される**ことを固定する。 +/// +/// 後者が本 PR の要点: 以前は stage ごとに独立解決で、`[diff]` だけが PR 範囲を +/// 見ていない非対称を許していた (todo 順位 288、4 回再発)。 +mod base_branch_resolution { + use super::*; + + const MINIMAL: &str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "t" +commands = ["echo ok"] + +[takt] +workflow = "w" +task = "t" + +[push] +command = "echo push" + +[diff] +command = "jj diff --git -r {{PR_RANGE}}" +output_path = ".takt/d.txt" +"#; + + /// TOML の table 構文上、top-level key は**全 section より前**に置く必要が + /// あるため、prefix / suffix を分けて組み立てる。 + fn parse(top_level: &str, sections: &str) -> Config { + toml::from_str(&format!("{}{}{}", top_level, MINIMAL, sections)) + .expect("config should parse") + } + + #[test] + fn all_stages_share_the_same_range_by_default() { + let config = parse("", ""); + let expected = format!("{}..@", DEFAULT_BASE_BRANCH); + assert_eq!(config.diff_pr_range(), expected); + assert_eq!(config.docs_only_pr_range(), expected); + assert_eq!(config.pr_size_pr_range(), expected); + } + + #[test] + fn top_level_default_branch_applies_to_all_stages() { + let config = parse("default_branch = \"main\"\n", ""); + assert_eq!(config.diff_pr_range(), "main..@"); + assert_eq!(config.docs_only_pr_range(), "main..@"); + assert_eq!(config.pr_size_pr_range(), "main..@"); + } + + /// 後方互換: 既存の派生プロジェクト config が持つ section 側の + /// `default_branch` は top-level より優先される。この divergence を + /// 個々の resolve 関数レベルでは許すが、`load_config` の + /// `validate_config` は stage 間の不一致を fail-closed で拒否する + /// (`validate_config_rejects_disagreeing_section_override` 参照。 + /// SIM-NEW-config-mod-rs-L69)。 + #[test] + fn section_override_wins_over_top_level() { + let config = parse( + "default_branch = \"main\"\n", + "\n[pr_size_check]\nenabled = true\ndefault_branch = \"develop\"\n", + ); + assert_eq!(config.pr_size_pr_range(), "develop..@"); + assert_eq!( + config.diff_pr_range(), + "main..@", + "override は指定した section にのみ効く" + ); + } + + #[test] + fn blank_override_falls_back_instead_of_producing_empty_range() { + let config = parse( + "default_branch = \"main\"\n", + "\n[pr_size_check]\nenabled = true\ndefault_branch = \" \"\n", + ); + assert_eq!( + config.pr_size_pr_range(), + "main..@", + "空白のみの override は未設定として扱う (`..@` を作らない)" + ); + } + + #[test] + fn blank_top_level_falls_back_to_default() { + let config = parse("default_branch = \"\"\n", ""); + assert_eq!(config.diff_pr_range(), format!("{}..@", DEFAULT_BASE_BRANCH)); + } + + /// SIM-NEW-config-mod-rs-L69: section override が top-level / 他 section と + /// 食い違う config は、`section_override_wins_over_top_level` が示す通り + /// 個々の resolve 関数では「解決できてしまう」が、`load_config` が呼ぶ + /// `validate_config` はこれを fail-closed で拒否しなければならない。 + #[test] + fn validate_config_rejects_disagreeing_section_override() { + let config = parse( + "default_branch = \"main\"\n", + "\n[pr_size_check]\nenabled = true\ndefault_branch = \"develop\"\n", + ); + let err = validate_config(&config).expect_err("disagreeing ranges must fail-closed"); + assert!(err.contains("PR 範囲が stage 間で一致しません"), "{err}"); + } + + /// 全 stage が同じ値を明示していれば (本リポジトリの + /// `push-runner-config.toml` の実運用形) 検証を通す。 + #[test] + fn validate_config_accepts_matching_section_overrides() { + let config = parse( + "default_branch = \"main\"\n", + "\n[pr_size_check]\nenabled = true\ndefault_branch = \"main\"\n\ + \n[docs_only_routing]\nenabled = true\ndefault_branch = \"main\"\n", + ); + assert!(validate_config(&config).is_ok()); + } + + /// CodeRabbit #313: top-level 不在でも section override が全一致していれば、 + /// override 未設定の stage (例 [diff]) もその値を共有する。旧実装は [diff] が + /// `DEFAULT_BASE_BRANCH` に落ち、他 section が "main" のとき「一致しない」と誤って + /// reject していた (valid legacy config の誤 reject)。 + #[test] + fn absent_top_level_inherits_agreed_section_overrides() { + let config = parse( + "", + "\n[pr_size_check]\nenabled = true\ndefault_branch = \"main\"\n\ + \n[docs_only_routing]\nenabled = true\ndefault_branch = \"main\"\n", + ); + assert_eq!( + config.diff_pr_range(), + "main..@", + "override 未設定の [diff] も全一致値を共有する" + ); + assert_eq!(config.docs_only_pr_range(), "main..@"); + assert_eq!(config.pr_size_pr_range(), "main..@"); + assert!( + validate_config(&config).is_ok(), + "一致する section override のみ (top-level 不在) の config は通す" + ); + } + + /// top-level 不在で section override が食い違う場合は従来どおり fail-closed + /// (genuine な不一致は緩めない)。 + #[test] + fn absent_top_level_with_disagreeing_section_overrides_still_fails_closed() { + let config = parse( + "", + "\n[pr_size_check]\nenabled = true\ndefault_branch = \"main\"\n\ + \n[docs_only_routing]\nenabled = true\ndefault_branch = \"develop\"\n", + ); + assert!( + validate_config(&config).is_err(), + "食い違う section override は genuine な不一致として reject" + ); + } +} + +/// CodeRabbit #313: `[diff] command` が `{{PR_RANGE}}` を欠く config は config-load 時に +/// fail-closed で拒否する (legacy の `-r @` 直書きを設定時点で弾く)。 +#[test] +fn validate_config_rejects_diff_command_without_pr_range_placeholder() { + let toml_str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "t" +commands = ["echo ok"] + +[diff] +command = "jj diff -r @" +output_path = ".takt/d.txt" + +[takt] +workflow = "w" +task = "t" + +[push] +command = "echo push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let err = validate_config(&config) + .expect_err("{{PR_RANGE}} を欠く [diff] command は fail-closed で拒否"); + assert!( + err.contains(DIFF_PR_RANGE_PLACEHOLDER), + "診断に placeholder 名を含めること: {err}" + ); +} + +/// `{{PR_RANGE}}` を含む modern な command は通す (本リポジトリの実運用形)。 +#[test] +fn validate_config_accepts_diff_command_with_pr_range_placeholder() { + let toml_str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "t" +commands = ["echo ok"] + +[diff] +command = "jj diff --git -r {{PR_RANGE}}" +output_path = ".takt/d.txt" + +[takt] +workflow = "w" +task = "t" + +[push] +command = "echo push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(validate_config(&config).is_ok()); +} + +#[test] +fn config_parses_full_without_diff() { + let toml_str = r#" +[quality_gate] +parallel = true +step_timeout = 60 + +[[quality_gate.groups]] +name = "lint" +commands = ["pnpm lint"] + +[[quality_gate.groups]] +name = "test" +pre = "pnpm install" +commands = ["pnpm test", "pnpm test:e2e"] + +[takt] +workflow = "pre-push-review" +task = "pre-push review" +extra_args = ["--pipeline", "--skip-git"] + +[push] +command = "jj git push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + + assert_eq!(config.quality_gate.parallel, Some(true)); + assert_eq!(config.quality_gate.step_timeout, Some(60)); + assert_eq!(config.quality_gate.groups.len(), 2); + assert!(config.diff.is_none()); + + assert_eq!(config.takt.workflow, "pre-push-review"); + assert_eq!(config.takt.task, "pre-push review"); + assert_eq!(config.takt.extra_args.as_ref().unwrap().len(), 2); + + assert_eq!(config.push.command, "jj git push"); + assert!(config.push.timeout.is_none()); +} + +#[test] +fn config_push_timeout_explicit() { + let toml_str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "test" +commands = ["echo ok"] + +[takt] +workflow = "w" +task = "t" + +[push] +command = "jj git push" +timeout = 600 +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.push.timeout, Some(600)); + assert_eq!( + config.push.timeout.unwrap_or(DEFAULT_PUSH_TIMEOUT_SECS), + 600, + ); +} + +#[test] +fn config_push_timeout_defaults() { + let toml_str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "test" +commands = ["echo ok"] + +[takt] +workflow = "w" +task = "t" + +[push] +command = "echo push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(config.push.timeout.is_none()); + assert_eq!( + config.push.timeout.unwrap_or(DEFAULT_PUSH_TIMEOUT_SECS), + DEFAULT_PUSH_TIMEOUT_SECS, + ); +} + +#[test] +fn config_parses_with_diff() { + let toml_str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "test" +commands = ["echo ok"] + +[diff] +command = "jj diff -r @" +output_path = ".takt/review-diff.txt" + +[takt] +workflow = "pre-push-review" +task = "pre-push review" + +[push] +command = "jj git push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + + let diff = config.diff.unwrap(); + assert_eq!(diff.command, "jj diff -r @"); + assert_eq!(diff.output_path, ".takt/review-diff.txt"); + assert!(diff.timeout.is_none()); +} + +/// T6: `[diff] timeout` 未指定時は既定値に落ちる (本リポジトリの config は未指定)。 +#[test] +fn config_diff_timeout_defaults() { + let toml_str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "test" +commands = ["echo ok"] + +[diff] +command = "jj diff -r @" +output_path = ".takt/review-diff.txt" + +[takt] +workflow = "w" +task = "t" + +[push] +command = "echo push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + let diff = config.diff.unwrap(); + assert!(diff.timeout.is_none()); + assert_eq!( + diff.timeout.unwrap_or(DEFAULT_DIFF_TIMEOUT_SECS), + DEFAULT_DIFF_TIMEOUT_SECS, + ); +} + +/// T6: 大 diff / 低速環境向けの escape hatch (既定 60s では足りない場合)。 +#[test] +fn config_diff_timeout_explicit() { + let toml_str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "test" +commands = ["echo ok"] + +[diff] +command = "jj diff -r @" +output_path = ".takt/review-diff.txt" +timeout = 180 + +[takt] +workflow = "w" +task = "t" + +[push] +command = "echo push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.diff.unwrap().timeout, Some(180)); +} + +#[test] +fn config_quality_gate_defaults() { + let toml_str = r#" +[quality_gate] + +[[quality_gate.groups]] +name = "test" +commands = ["echo ok"] + +[takt] +workflow = "w" +task = "t" + +[push] +command = "echo push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(config.quality_gate.parallel.unwrap_or(true)); + assert_eq!( + config + .quality_gate + .step_timeout + .unwrap_or(DEFAULT_STEP_TIMEOUT_SECS), + DEFAULT_STEP_TIMEOUT_SECS, + ); + assert!(config.takt.extra_args.is_none()); +} + +#[test] +fn config_pre_field_optional() { + let toml_str = r#" +[quality_gate] +[[quality_gate.groups]] +name = "no-pre" +commands = ["echo test"] + +[[quality_gate.groups]] +name = "with-pre" +pre = "echo install" +commands = ["echo test"] + +[takt] +workflow = "w" +task = "t" + +[push] +command = "echo push" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert!(config.quality_gate.groups[0].pre.is_none()); + assert!(config.quality_gate.groups[1].pre.is_some()); +} + +#[test] +fn validate_rejects_empty_groups() { + let config = Config { + default_branch: None, + quality_gate: QualityGateConfig { + parallel: None, + step_timeout: None, + groups: vec![], + }, + diff: None, + lint_screen: None, + scratch_file_warning: None, + pr_size_check: None, + pre_push_review: None, + docs_only_routing: None, + post_takt_regate: None, + takt: TaktConfig { + workflow: "w".into(), + task: "t".into(), + extra_args: None, + }, + push: PushConfig { + command: "echo".into(), + timeout: None, + }, + }; + let result = validate_config(&config); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("groups が空")); +} + +#[test] +fn validate_rejects_empty_commands() { + let config = Config { + default_branch: None, + quality_gate: QualityGateConfig { + parallel: None, + step_timeout: None, + groups: vec![GroupConfig { + name: "empty".into(), + pre: None, + commands: vec![], + }], + }, + diff: None, + lint_screen: None, + scratch_file_warning: None, + pr_size_check: None, + pre_push_review: None, + docs_only_routing: None, + post_takt_regate: None, + takt: TaktConfig { + workflow: "w".into(), + task: "t".into(), + extra_args: None, + }, + push: PushConfig { + command: "echo".into(), + timeout: None, + }, + }; + let result = validate_config(&config); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("'empty'")); +} + +/// resolve_takt_workflow テスト用に base config + 任意の [pre_push_review] +/// section を組み立てる。base workflow は "pre-push-review"。 +fn config_with_optional_pre_push(pre_push_section: &str) -> Config { + let toml_str = format!( + r#" +[quality_gate] +[[quality_gate.groups]] +name = "test" +commands = ["echo ok"] + +[takt] +workflow = "pre-push-review" +task = "pre-push review" + +[push] +command = "echo push" +{pre_push_section} +"# + ); + toml::from_str(&toml_str).unwrap() +} + +#[test] +fn resolve_workflow_base_when_section_absent() { + let config = config_with_optional_pre_push(""); + assert_eq!(resolve_takt_workflow(&config), "pre-push-review"); +} + +#[test] +fn resolve_workflow_base_when_refute_disabled() { + let config = config_with_optional_pre_push( + "[pre_push_review]\nrefute_enabled = false\nrefute_workflow = \"pre-push-review-refute\"", + ); + assert_eq!(resolve_takt_workflow(&config), "pre-push-review"); +} + +#[test] +fn resolve_workflow_refute_when_enabled() { + let config = config_with_optional_pre_push( + "[pre_push_review]\nrefute_enabled = true\nrefute_workflow = \"pre-push-review-refute\"", + ); + assert_eq!(resolve_takt_workflow(&config), "pre-push-review-refute"); +} + +#[test] +fn resolve_workflow_base_when_enabled_but_no_refute_workflow() { + let config = config_with_optional_pre_push("[pre_push_review]\nrefute_enabled = true"); + assert_eq!(resolve_takt_workflow(&config), "pre-push-review"); +} diff --git a/src/cli-push-runner/src/main.rs b/src/cli-push-runner/src/main.rs index fc640519..d88ca14f 100644 --- a/src/cli-push-runner/src/main.rs +++ b/src/cli-push-runner/src/main.rs @@ -67,7 +67,8 @@ fn run_diff_and_lint_screen(config: &config::Config) -> Result { let Some(diff_config) = &config.diff else { return Ok(DiffGate::RunTakt { pre_diff: None }); }; - let diff_path = match run_diff(diff_config) { + let pr_range = config.diff_pr_range(); + let diff_path = match run_diff(diff_config, &pr_range) { DiffResult::HasContent => diff_config.output_path.as_str(), DiffResult::Empty => { log_info("diff が空のためレビューをスキップして push に進みます。"); @@ -100,7 +101,7 @@ fn run_pre_checks(config: &config::Config) -> Result, i32> { ); return Err(EXIT_SCRATCH_FILE_WARNING); } - if !run_pr_size_check(config.pr_size_check.as_ref()) { + if !run_pr_size_check(config.pr_size_check.as_ref(), &config.pr_size_pr_range()) { log_info( "パイプライン中断: PR diff サイズが block_threshold を超過。\ PR 分割 / 閾値調整 / `PR_SIZE_CHECK_OVERRIDE=1` のいずれかで再実行してください。", @@ -195,7 +196,7 @@ fn run_stages(metrics: &mut RunMetrics) -> i32 { metrics.set_bookmarks(&detected_bookmarks); let skip_groups = metrics.timed("docs_only_routing", || { - run_docs_only_routing(config.docs_only_routing.as_ref()) + run_docs_only_routing(config.docs_only_routing.as_ref(), &config.docs_only_pr_range()) }); metrics.set_skipped_groups(&skip_groups); diff --git a/src/cli-push-runner/src/stages/diff.rs b/src/cli-push-runner/src/stages/diff.rs index 6ba3b726..a8272fe7 100644 --- a/src/cli-push-runner/src/stages/diff.rs +++ b/src/cli-push-runner/src/stages/diff.rs @@ -2,13 +2,26 @@ //! //! 出力は takt の reviewers が Read で参照するレビュー対象そのもののため、 //! **切り詰めない** (`run_diff_cmd` の doc)。実行は timeout 付き (T6)。 +//! +//! ## レビュー範囲は PR 全体 +//! +//! `[diff] command` の [`DIFF_PR_RANGE_PLACEHOLDER`] は `Config::diff_pr_range()` +//! (= `..@`) に展開される。以前は config に `jj diff -r @` と直書きされており、 +//! **tip コミットしかレビュアーに渡らなかった**。祖先コミットは AI レビューを一度も +//! 経ずに merge され、しかもレビュアー側からは「渡された diff が PR 全体か」を +//! 検証できないため誤りが誰にも検知されなかった (todo 順位 288、Severity High で +//! PR #268/#300/#301/#311 と 4 回再発)。 +//! +//! 範囲の直書きを禁じるだけでは派生プロジェクトの古い config を救えないため、 +//! [`verify_diff_covers_pr_range`] が生成された diff と PR 範囲の変更ファイル集合を +//! 突き合わせ、不足があれば fail-closed で中断する。 use std::path::Path; use std::process::Stdio; use lib_subprocess::{drain_pipe_unlimited, shell_command, wait_with_timeout_safe}; -use crate::config::{DiffConfig, DEFAULT_DIFF_TIMEOUT_SECS}; +use crate::config::{DiffConfig, DEFAULT_DIFF_TIMEOUT_SECS, DIFF_PR_RANGE_PLACEHOLDER}; use crate::log::log_stage; #[derive(Debug, PartialEq)] @@ -83,6 +96,128 @@ fn run_diff_cmd(cmd: &str, timeout_secs: u64) -> Result { } } +/// レビュー対象 diff が PR 範囲の全変更ファイルを含むか検査する (fail-closed / ADR-043)。 +/// +/// **なぜ必要か**: `[diff] command` は config 由来の自由文字列で、`-r @` のように +/// PR より狭い範囲を書けてしまう。狭い範囲を書いても reviewers 側からは「渡された +/// diff が全体か」を検証できず、正しく「docs-only」等と判定してしまうため、 +/// 誤りが誰にも検知されないまま merge に至る (todo 順位 288、Severity High で 4 回再発)。 +/// 検査の真実源は `jj diff --summary` = docs_only_routing / pr_size_check と同じ経路。 +/// +/// 判定不能 (summary 取得失敗 / summary の行が 1 つでもパースできない / diff がヘッダを +/// 持たない) は「網羅している」に倒さずエラーにする。「検証できない」を「検証した」と +/// 扱わないための線引き。 +/// +/// パースの厳格性は [`parse_summary_paths`] が担う: **1 行でも解釈できない行があれば +/// `Err`**。妥当な行だけ拾って未知行を silent drop すると、jj の出力書式が一部だけ +/// 変わったとき (例: 一部行だけ新 status) に、変わっていない行で `expected` が非空になり +/// coverage を通過してしまう — 本 PR が塞いでいる「誤りが誰にも検知されない」構造その +/// ものを gate 自身が再生産する (CodeRabbit #313: per-line fail-open)。 +fn verify_diff_covers_pr_range( + diff_output: &str, + fetch_summary: impl FnOnce() -> Result, +) -> Result<(), String> { + let summary = fetch_summary().map_err(|e| format!("PR 範囲の summary 取得に失敗: {}", e))?; + + let expected = parse_summary_paths(&summary).map_err(|e| { + format!( + "summary をパースできませんでした: {}。jj の出力書式が変わった可能性があります", + e + ) + })?; + if expected.is_empty() { + return Ok(()); + } + + let covered = parse_git_diff_paths(diff_output); + if covered.is_empty() { + return Err( + "diff 出力に `diff --git` ヘッダが無く、対象ファイルを特定できません".to_string(), + ); + } + + let missing: Vec<&String> = expected.iter().filter(|p| !covered.contains(*p)).collect(); + if missing.is_empty() { + return Ok(()); + } + Err(format!( + "{} ファイルが未収録 (例: {})", + missing.len(), + missing + .iter() + .take(3) + .map(|s| s.as_str()) + .collect::>() + .join(", ") + )) +} + +/// `jj diff --summary` の `M path` / `R old new` (rename) / `C old new` (copy) +/// 形式からパス集合を作る。 +/// +/// **空行を除く 1 行でも解釈できなければ `Err`** を返す (CodeRabbit #313)。妥当な行 +/// だけを拾って未知行を silent drop すると、jj の出力書式が一部だけ変わったとき +/// (一部の行だけ新 status / status とパスに分割不能) に、変わっていない行で `expected` +/// が非空になり `verify_diff_covers_pr_range` の coverage 検査を通過してしまう。 +/// 全行が未知のときだけ fail-closed する旧実装 (SIM-NEW-diff-rs-L178) の per-line 版。 +/// 全行が空 (末尾改行のみ 等) なら `Ok` の空集合を返す (= 変更なし)。 +/// +/// `R`/`C` は ` ` の 3 トークン形式 (`lib_docs_policy` の +/// `is_docs_only_summary` テストが同じ `"R docs/a.md docs/b.md"` 形状を実証)。 +/// `parse_git_diff_paths` (`diff --git a/old b/new` の new = `b/` 側のみ拾う) と +/// 揃えるため new path 側だけを採用する (SIM-NEW-diff-rs-L146)。 +/// +/// Windows の jj は `\` 区切りで出すため `/` に正規化して `--git` 側と突き合わせる。 +fn parse_summary_paths( + summary: &str, +) -> Result, String> { + let mut paths = std::collections::BTreeSet::new(); + for line in summary.lines() { + if line.trim().is_empty() { + continue; + } + let (status, rest) = line + .split_once(' ') + .ok_or_else(|| format!("status とパスに分割できない行: {:?}", line))?; + let path = summary_line_new_path(status, rest) + .ok_or_else(|| format!("未知 status / new path 欠落の行: {:?}", line))?; + paths.insert(path); + } + Ok(paths) +} + +/// 1 行分の ` ` から採用すべきパスを返す (rename/copy は new path のみ)。 +/// 受理できない行は `None` を返し、呼び出し側 [`parse_summary_paths`] が `Err` に昇格して +/// fail-closed に倒す。 +/// +/// `None` になるのは: 未知 status (`jj diff --summary` の M/A/D/R/C 以外) / R・C で +/// new path (末尾トークン) が無い崩れた行 / パスが空。catch-all で任意 status や崩れた +/// R/C を通すと、jj の出力書式が変わった行を「妥当なパス」として取り込み、書式変化を +/// 検知できず gate が沈黙する (SIM-NEW-diff-rs-L178)。旧実装は崩れた R/C の生トークンを +/// 残して coverage 不一致に頼っていたが、CodeRabbit #313 指摘に従い「未知 status も崩れた +/// R/C も明示的に reject」へ統一する (fail-closed の判定を coverage 副作用でなく +/// パース時点に前倒しする)。 +fn summary_line_new_path(status: &str, rest: &str) -> Option { + let trimmed = rest.trim(); + let path = match status { + "M" | "A" | "D" => trimmed, + "R" | "C" => trimmed.rsplit_once(' ').map(|(_, new)| new)?, + _ => return None, + }; + (!path.is_empty()).then(|| path.replace('\\', "/")) +} + +/// unified diff の `diff --git a/X b/X` ヘッダからパス集合を作る。 +fn parse_git_diff_paths(diff_output: &str) -> std::collections::BTreeSet { + diff_output + .lines() + .filter_map(|line| line.strip_prefix("diff --git ")) + .filter_map(|rest| rest.split_once(" b/")) + .map(|(_a_side, b_path)| b_path.trim().replace('\\', "/")) + .filter(|p| !p.is_empty()) + .collect() +} + /// takt 実行後の diff snapshot を取得する (T12 post-takt re-gate の変化検出用)。 /// /// Stage 1.5 と同じ `[diff] command` を再実行し stdout を返す。呼び出し側 (re-gate) は @@ -91,17 +226,36 @@ fn run_diff_cmd(cmd: &str, timeout_secs: u64) -> Result { /// 呼び出し側が fail-closed (= 変化ありとみなし re-gate 実行) に扱う (ADR-043)。 /// /// `run_diff` と違いファイルには書かない (比較のためメモリ上で保持するだけ)。timeout / -/// stderr 分離の要件は `run_diff_cmd` と同一 (同 doc 参照)。 -pub(crate) fn capture_diff_snapshot(config: &DiffConfig) -> Option { +/// stderr 分離の要件は `run_diff_cmd` と同一 (同 doc 参照)。範囲カバレッジ検査も +/// 行わない (前後比較が目的で、レビュー入力にはならないため)。 +pub(crate) fn capture_diff_snapshot(config: &DiffConfig, pr_range: &str) -> Option { let timeout = config.timeout.unwrap_or(DEFAULT_DIFF_TIMEOUT_SECS); - run_diff_cmd(&config.command, timeout).ok() + run_diff_cmd(&resolve_diff_command(&config.command, pr_range), timeout).ok() } -pub(crate) fn run_diff(config: &DiffConfig) -> DiffResult { - log_stage("diff", &format!("実行: {}", config.command)); +/// `[diff] command` の [`DIFF_PR_RANGE_PLACEHOLDER`] を PR 範囲 revset に展開する。 +pub(crate) fn resolve_diff_command(command: &str, pr_range: &str) -> String { + command.replace(DIFF_PR_RANGE_PLACEHOLDER, pr_range) +} + +pub(crate) fn run_diff(config: &DiffConfig, pr_range: &str) -> DiffResult { + run_diff_with(config, pr_range, || { + super::docs_only_routing::run_jj_diff_summary(pr_range) + }) +} + +/// `run_diff` の本体。PR 範囲の summary 取得を注入可能にして、範囲カバレッジ検査を +/// jj 実行なしでテストできるようにする (`post_takt_regate::decide_regate` と同じ流儀)。 +fn run_diff_with( + config: &DiffConfig, + pr_range: &str, + fetch_summary: impl FnOnce() -> Result, +) -> DiffResult { + let command = resolve_diff_command(&config.command, pr_range); + log_stage("diff", &format!("実行: {}", command)); let timeout = config.timeout.unwrap_or(DEFAULT_DIFF_TIMEOUT_SECS); - let output = match run_diff_cmd(&config.command, timeout) { + let output = match run_diff_cmd(&command, timeout) { Ok(output) => output, Err(err) => { log_stage("diff", "diff コマンド失敗"); @@ -112,6 +266,11 @@ pub(crate) fn run_diff(config: &DiffConfig) -> DiffResult { } }; + if let Err(reason) = verify_diff_covers_pr_range(&output, fetch_summary) { + report_coverage_failure(pr_range, &reason); + return DiffResult::Error; + } + if output.is_empty() { log_stage( "diff", @@ -120,7 +279,25 @@ pub(crate) fn run_diff(config: &DiffConfig) -> DiffResult { return DiffResult::Empty; } - let path = Path::new(&config.output_path); + write_diff_output(&config.output_path, &output) +} + +/// 範囲検査に落ちたときの fail-closed 通知 (ADR-043)。 +/// +/// 「レビュー範囲が PR より狭い」ことは検知できても自動修復はできない +/// (どこまで広げるべきかは config の意図次第) ため、push を止めて人間に返す。 +fn report_coverage_failure(pr_range: &str, reason: &str) { + log_stage("diff", &format!("レビュー範囲の検査に失敗: {}", reason)); + eprintln!( + "[push-runner] [diff] レビュー対象 diff が PR 範囲 ({}) を網羅していません: {}\n\ + このまま進めると祖先コミットが AI レビューを経ずに merge されます (todo 順位 288)。\n\ + `[diff] command` が `{}` を使っているか、出力が unified diff (--git) 形式かを確認してください。", + pr_range, reason, DIFF_PR_RANGE_PLACEHOLDER + ); +} + +fn write_diff_output(output_path: &str, output: &str) -> DiffResult { + let path = Path::new(output_path); if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { log_stage("diff", &format!("ディレクトリ作成失敗: {}", e)); @@ -128,12 +305,12 @@ pub(crate) fn run_diff(config: &DiffConfig) -> DiffResult { } } - match std::fs::write(path, &output) { + match std::fs::write(path, output) { Ok(()) => { let line_count = output.lines().count(); log_stage( "diff", - &format!("書き出し完了: {} ({} 行)", config.output_path, line_count), + &format!("書き出し完了: {} ({} 行)", output_path, line_count), ); DiffResult::HasContent } @@ -145,239 +322,4 @@ pub(crate) fn run_diff(config: &DiffConfig) -> DiffResult { } #[cfg(test)] -mod tests { - use super::*; - - /// 100 行を吐くコマンド。cmd.exe と POSIX sh で構文が非互換なため OS 別に - /// 出し分ける (WP-15)。POSIX 側は `seq` 不在の最小環境でも動く while ループ。 - #[cfg(windows)] - const EMIT_100_LINES_CMD: &str = "for /L %i in (1,1,100) do @echo line %i"; - #[cfg(not(windows))] - const EMIT_100_LINES_CMD: &str = "i=1; while [ $i -le 100 ]; do echo line $i; i=$((i+1)); done"; - - /// 何も出力せず正常終了するコマンド (0 バイト出力の検証用)。 - #[cfg(windows)] - const ZERO_BYTE_OUTPUT_CMD: &str = "type nul"; - #[cfg(not(windows))] - const ZERO_BYTE_OUTPUT_CMD: &str = "true"; - - /// stderr へ出力してから非 0 で終わるコマンド (失敗診断の検証用)。 - #[cfg(windows)] - const STDERR_THEN_FAIL_CMD: &str = "echo boom 1>&2& exit /b 1"; - #[cfg(not(windows))] - const STDERR_THEN_FAIL_CMD: &str = "echo boom 1>&2; exit 1"; - - /// stdout と stderr の両方へ出しつつ正常終了するコマンド。 - /// stderr (jj の警告相当) が diff 本体に混ざらない契約の検証用。 - #[cfg(windows)] - const STDOUT_AND_STDERR_CMD: &str = "echo real diff& echo Concurrent modification 1>&2"; - #[cfg(not(windows))] - const STDOUT_AND_STDERR_CMD: &str = "echo real diff; echo Concurrent modification 1>&2"; - - #[test] - fn run_diff_cmd_captures_more_than_40_lines() { - let result = run_diff_cmd(EMIT_100_LINES_CMD, 30); - assert!(result.is_ok(), "command should succeed"); - let output = result.unwrap(); - let line_count = output.lines().count(); - assert!( - line_count > 40, - "expected >40 lines captured, got {}; run_diff_cmd must not apply the 40-line cap", - line_count - ); - } - - #[test] - fn run_diff_returns_empty_when_output_is_empty() { - let out_path = std::env::temp_dir().join("test-run-diff-empty.txt"); - let _ = std::fs::remove_file(&out_path); - - let config = DiffConfig { - command: ZERO_BYTE_OUTPUT_CMD.to_string(), - output_path: out_path.to_string_lossy().into_owned(), - timeout: None, - }; - - let result = run_diff(&config); - - assert_eq!( - result, - DiffResult::Empty, - "run_diff must return Empty when the diff command produces empty output" - ); - assert!( - !out_path.exists(), - "output file must not be created for an empty diff" - ); - } - - /// T12: capture_diff_snapshot は成功時に stdout を Some で返す - /// (post-takt re-gate の pre/post 比較の材料)。 - #[test] - fn capture_diff_snapshot_returns_output_on_success() { - let config = DiffConfig { - command: "echo snapshot-content".to_string(), - output_path: String::new(), - timeout: None, - }; - let snap = capture_diff_snapshot(&config).expect("成功時は Some"); - assert!( - snap.contains("snapshot-content"), - "stdout をそのまま返すこと: {:?}", - snap - ); - } - - /// T12: 取得失敗 (コマンド exit 非 0) は None を返す - /// (呼び出し側は None を fail-closed = 変化ありに倒す)。 - #[test] - fn capture_diff_snapshot_returns_none_on_failure() { - let config = DiffConfig { - command: STDERR_THEN_FAIL_CMD.to_string(), - output_path: String::new(), - timeout: Some(30), - }; - assert!( - capture_diff_snapshot(&config).is_none(), - "失敗時は None (呼び出し側で fail-closed に扱う)" - ); - } - - /// T6 回帰テスト群: diff stage に timeout が無く無限ハングし得た不具合 - /// (ADR-049 の流儀: 1 test = 1 failure mode + good/bad)。 - /// - /// 由来: 2026-07-16 の push パイプライン調査 (コード監査で発見。T5 と同じく - /// in the wild の発火記録は無く、「他 stage は全て timeout 付き = diff だけが穴」 - /// という非対称として特定された)。 - /// - /// 事故の形: `run_diff_cmd` は `Command::output()` で子プロセスの終了を**無限に** - /// 待っていた。ADR-045 の並列 workspace 運用で jj の lock 競合が起きると - /// `pnpm push` は診断も timeout も無いまま停止し、ユーザーは手動 kill するしかない。 - /// - /// 修正の核心は「timeout 付きで待ち、超過時は Err → `DiffResult::Error` = exit 5 で - /// 中断する (fail-closed / ADR-043)」。あわせて、判定に使う stdout を stderr と - /// 混ぜない契約 (レビュー対象を汚さない) も本 mod で seal する。 - mod t6_diff_timeout { - use super::*; - use std::time::{Duration, Instant}; - - /// 実行し続けるコマンド (ハングした jj の代役)。timeout が無ければ約 9s 待たされる。 - /// cmd.exe と POSIX sh で構文が非互換なため OS 別に出し分ける (WP-15)。 - /// 所要時間を両 OS で揃えないと片側だけ timeout 経路を検証しない穴になる。 - #[cfg(windows)] - const HANGING_COMMAND: &str = "ping 127.0.0.1 -n 10"; - #[cfg(not(windows))] - const HANGING_COMMAND: &str = "sleep 10"; - - const SHORT_TIMEOUT_SECS: u64 = 1; - - /// incident 再現 (bad): 応答しないコマンドを **timeout で打ち切る**こと。 - /// 修正前は `Command::output()` が返るまで待ち続け、本 assert には到達しなかった。 - #[test] - fn hanging_command_times_out_instead_of_waiting_forever() { - let started = Instant::now(); - let result = run_diff_cmd(HANGING_COMMAND, SHORT_TIMEOUT_SECS); - let elapsed = started.elapsed(); - - let err = result.expect_err("timeout は Err で返ること (無限待ちしない)"); - assert!( - err.contains("タイムアウト"), - "timeout と判る診断を返すこと: {:?}", - err, - ); - assert!( - elapsed < Duration::from_secs(5), - "timeout ({}s) 後すぐ制御を返すこと。{:?} 掛かった = コマンドの自然終了を\ - 待っている (T6 の不具合)", - SHORT_TIMEOUT_SECS, - elapsed, - ); - } - - /// timeout の診断は原因調査に足りること: 超過秒数と実行コマンドを含む。 - #[test] - fn timeout_error_reports_the_limit_and_the_command() { - let err = run_diff_cmd(HANGING_COMMAND, SHORT_TIMEOUT_SECS) - .expect_err("timeout は Err で返ること"); - assert!( - err.contains(&format!("{}s", SHORT_TIMEOUT_SECS)) && err.contains(HANGING_COMMAND), - "超過秒数と実行コマンドを診断に含めること: {:?}", - err, - ); - } - - /// timeout は fail-closed で pipeline を止めること (ADR-043)。 - /// `DiffResult::Error` は main.rs で exit 5 = 中断になる。空 diff 扱いで - /// **レビューを skip したまま push に進んではならない**。 - #[test] - fn timeout_aborts_the_pipeline_and_writes_no_diff_file() { - let out_path = std::env::temp_dir().join("test-run-diff-timeout.txt"); - let _ = std::fs::remove_file(&out_path); - - let config = DiffConfig { - command: HANGING_COMMAND.to_string(), - output_path: out_path.to_string_lossy().into_owned(), - timeout: Some(SHORT_TIMEOUT_SECS), - }; - - assert_eq!( - run_diff(&config), - DiffResult::Error, - "timeout は Error (= exit 5 で中断) になること。Empty だとレビューを\ - skip して push に進んでしまう", - ); - assert!( - !out_path.exists(), - "timeout 時に diff ファイルを書かないこと (古い/欠けた diff でレビューさせない)", - ); - } - - /// good: timeout 内に終わるコマンドを誤って打ち切らないこと。 - #[test] - fn command_within_the_timeout_succeeds() { - let output = run_diff_cmd("echo diff line", 30).expect("即終了するコマンドは Ok"); - assert!(output.contains("diff line"), "stdout を返すこと: {:?}", output); - } - - /// `[diff] timeout` 未指定なら既定値が使われること (既定値の適用漏れ防止)。 - #[test] - fn absent_config_timeout_falls_back_to_the_default() { - let config = DiffConfig { - command: "echo ok".to_string(), - output_path: std::env::temp_dir() - .join("test-run-diff-default-timeout.txt") - .to_string_lossy() - .into_owned(), - timeout: None, - }; - assert_eq!( - config.timeout.unwrap_or(DEFAULT_DIFF_TIMEOUT_SECS), - DEFAULT_DIFF_TIMEOUT_SECS, - ); - assert_eq!(run_diff(&config), DiffResult::HasContent); - let _ = std::fs::remove_file(&config.output_path); - } - - /// stderr を stdout に混ぜないこと: stdout は reviewers が読む diff そのものとして - /// ファイルに書かれるため、jj の警告 (並列 workspace 時の `Concurrent modification - /// detected` 等) が混入するとレビュー対象を汚す。`run_cmd_shell_*` (全 variant が - /// stdout/stderr を結合する) に載せ替えるとこのテストが落ちる。 - #[test] - fn stderr_is_not_merged_into_the_diff_output() { - let output = run_diff_cmd(STDOUT_AND_STDERR_CMD, 30).expect("exit 0 なら Ok"); - assert!(output.contains("real diff"), "stdout は残ること: {:?}", output); - assert!( - !output.contains("Concurrent modification"), - "stderr の警告が diff 内容に混入しないこと: {:?}", - output, - ); - } - - /// 失敗時は stderr を診断として返すこと (従来契約の維持)。 - #[test] - fn failure_returns_stderr_as_the_diagnostic() { - let err = run_diff_cmd(STDERR_THEN_FAIL_CMD, 30).expect_err("exit 1 は Err"); - assert!(err.contains("boom"), "stderr を診断に返すこと: {:?}", err); - } - } -} +mod tests; diff --git a/src/cli-push-runner/src/stages/diff/tests.rs b/src/cli-push-runner/src/stages/diff/tests.rs new file mode 100644 index 00000000..fc152ceb --- /dev/null +++ b/src/cli-push-runner/src/stages/diff/tests.rs @@ -0,0 +1,506 @@ +//! diff stage のテスト (production は ../diff.rs)。ファイル長 800 行ガイドライン +//! (順位 147) 遵守のため #312 pipeline_lock と同じく test mod を切り出した。 + +use super::*; + +/// 範囲検査を jj 非依存でテストするための PR 範囲 revset。 +fn test_pr_range() -> String { + format!("{}..@", crate::config::DEFAULT_BASE_BRANCH) +} + +/// PR #268/#300/#301/#311 の incident 形状: PR に 2 ファイル変更があるのに、 +/// レビュー対象 diff には tip コミットの 1 ファイルしか入っていない。 +const INCIDENT_PR_SUMMARY: &str = "M docs/plan.md\nM src/checker/decide.rs\n"; +const INCIDENT_TIP_ONLY_DIFF: &str = + "diff --git a/docs/plan.md b/docs/plan.md\n@@ -1 +1 @@\n-a\n+b\n"; + +#[test] +fn resolve_diff_command_expands_pr_range_placeholder() { + let range = test_pr_range(); + assert_eq!( + resolve_diff_command("jj diff --git -r {{PR_RANGE}}", &range), + format!("jj diff --git -r {}", range) + ); +} + +/// base branch が `main` の派生プロジェクトでも展開が成立すること +/// (rule⑫ が防ごうとしている alternative branch の silent breakage)。 +#[test] +fn resolve_diff_command_expands_alternative_base_branch() { + assert_eq!( + resolve_diff_command("jj diff --git -r {{PR_RANGE}}", "main..@"), + "jj diff --git -r main..@" + ); +} + +/// incident 再現: レビュー対象 diff が PR 範囲より狭いことを検知する。 +/// 旧実装にはこの検査が無く、狭い diff が「全体」として reviewers に渡っていた。 +#[test] +fn verify_rejects_diff_narrower_than_pr_range() { + let result = verify_diff_covers_pr_range(INCIDENT_TIP_ONLY_DIFF, || { + Ok(INCIDENT_PR_SUMMARY.to_string()) + }); + let err = result.expect_err("PR 範囲より狭い diff は検知されなければならない"); + assert!( + err.contains("src/checker/decide.rs"), + "未収録ファイル名を示すこと: {}", + err + ); +} + +#[test] +fn verify_accepts_diff_covering_whole_pr_range() { + let full_diff = format!( + "{}diff --git a/src/checker/decide.rs b/src/checker/decide.rs\n@@ -1 +1 @@\n-x\n+y\n", + INCIDENT_TIP_ONLY_DIFF + ); + assert!( + verify_diff_covers_pr_range(&full_diff, || Ok(INCIDENT_PR_SUMMARY.to_string())).is_ok() + ); +} + +/// Windows の jj は `--summary` を `\` 区切りで出すため、正規化しないと +/// 全ファイルが「未収録」に見えて常時 fail する。 +#[test] +fn verify_normalizes_windows_path_separators() { + let summary = "M docs\\plan.md\n"; + assert!(verify_diff_covers_pr_range(INCIDENT_TIP_ONLY_DIFF, || Ok( + summary.to_string() + )) + .is_ok()); +} + +/// SIM-NEW-diff-rs-L146: rename 行 (`R `) は new path だけを +/// パス集合に採用すること。旧実装は old+new を 1 個の壊れたパスにしてしまい、 +/// coverage 検査が rename ファイルを絶対に "covered" と一致させられなかった。 +#[test] +fn parse_summary_paths_extracts_new_path_for_rename() { + let paths = parse_summary_paths("R docs/a.md docs/b.md\n").expect("valid rename は Ok"); + assert_eq!( + paths, + std::collections::BTreeSet::from(["docs/b.md".to_string()]), + "rename は new path のみを採用し、old path や結合パスを含めないこと: {:?}", + paths + ); +} + +/// copy 行 (`C `) も rename と同じ扱い (new path のみ採用)。 +#[test] +fn parse_summary_paths_extracts_new_path_for_copy() { + let paths = parse_summary_paths("C docs/a.md docs/c.md\n").expect("valid copy は Ok"); + assert_eq!( + paths, + std::collections::BTreeSet::from(["docs/c.md".to_string()]), + "copy は new path のみを採用すること: {:?}", + paths + ); +} + +/// incident 再現 (rename 版): rename されたファイルを含む PR 範囲でも、 +/// new path を収録した diff は coverage 検査を通ること。 +#[test] +fn verify_accepts_diff_with_renamed_file() { + let summary = "R docs/a.md docs/b.md\n"; + let diff = "diff --git a/docs/a.md b/docs/b.md\n@@ -1 +1 @@\n-old\n+new\n"; + assert!( + verify_diff_covers_pr_range(diff, || Ok(summary.to_string())).is_ok(), + "rename ファイルの new path が --git ヘッダの b/ 側と一致し、coverage 検査を\ + 通ること (旧実装は old+new の壊れたパスと一致せず常に未収録扱いだった)" + ); +} + +/// summary 取得に失敗したら「網羅している」に倒さない (fail-closed / ADR-043)。 +#[test] +fn verify_fails_closed_when_summary_unavailable() { + let result = + verify_diff_covers_pr_range(INCIDENT_TIP_ONLY_DIFF, || Err("jj 失敗".to_string())); + assert!( + result.is_err(), + "検証できないことを検証したものとして扱ってはならない" + ); +} + +/// jj 既定形式 (`diff --git` ヘッダを持たない) では収録ファイルを特定できないため +/// fail-closed にする。--git 形式への切替 (todo 順位 264) を機械的に要求する層。 +#[test] +fn verify_fails_closed_when_diff_has_no_git_headers() { + let plain_format_diff = "Modified regular file docs/plan.md:\n 1 1: -a\n"; + let result = verify_diff_covers_pr_range(plain_format_diff, || { + Ok(INCIDENT_PR_SUMMARY.to_string()) + }); + assert!(result.is_err(), "形式不明なら検証済みと扱わない"); +} + +/// PR 範囲が空 (変更なし) なら検査は素通しする (空 diff は上流で Empty 扱い)。 +#[test] +fn verify_passes_when_pr_range_is_empty() { + assert!(verify_diff_covers_pr_range("", || Ok(String::new())).is_ok()); +} + +/// security-review 指摘: summary に行はあるのに 1 件もパースできない (= jj の +/// 出力書式変更) 場合、空集合に丸めて素通しすると gate が無言で機能停止する。 +/// 「行はあるがパース不能」と「そもそも変更なし」を区別して前者は fail-closed。 +/// +/// 入力は**未知 status のみ**の行にして、意図した「パース不能」分岐を確実に突く +/// (旧テストは status を含む行が catch-all で妥当パス化され、後段の「missing files」 +/// 分岐でたまたま Err になっていた = 主張と別分岐を検証していた。SIM-NEW-diff-rs-L178)。 +#[test] +fn verify_fails_closed_when_summary_is_unparseable() { + let unparseable = "X docs/plan.md\nZ src/checker/decide.rs\n"; + let result = + verify_diff_covers_pr_range(INCIDENT_TIP_ONLY_DIFF, || Ok(unparseable.to_string())); + let err = result.expect_err("パース不能を素通しすると gate が無言で死ぬ"); + assert!( + err.contains("パースできませんでした"), + "『missing files』ではなく『summary パース不能』分岐で fail-closed すべき: {}", + err + ); +} + +/// CodeRabbit #313 (per-line fail-open): valid な docs/plan.md は diff に含まれるが、 +/// 未知 status の src/checker/decide.rs 行が混ざる。旧実装は未知行を silent drop し、 +/// docs/plan.md だけで coverage を通していた。1 行でも解釈不能なら fail-closed。 +#[test] +fn verify_fails_closed_when_summary_has_mixed_unparseable_line() { + let mixed = "M docs/plan.md\nX src/checker/decide.rs\n"; + let result = verify_diff_covers_pr_range(INCIDENT_TIP_ONLY_DIFF, || Ok(mixed.to_string())); + let err = result.expect_err("valid 行と未知行の混在は fail-closed"); + assert!( + err.contains("パースできませんでした"), + "混在 summary は『summary パース不能』分岐で fail-closed すべき: {}", + err + ); +} + +/// 未知 status 行が「妥当なパス」として取り込まれないこと (catch-all で status を +/// 素通しすると書式変化を検知できなくなる。SIM-NEW-diff-rs-L178)。 +#[test] +fn parse_summary_paths_rejects_unknown_status() { + assert!( + parse_summary_paths("X docs/plan.md\n").is_err(), + "未知 status 行は Err (silent drop しない)" + ); + assert_eq!( + parse_summary_paths("M docs/plan.md\n").expect("既知 status は Ok"), + std::collections::BTreeSet::from(["docs/plan.md".to_string()]), + "既知 status (M/A/D/R/C) は従来どおり受理する" + ); +} + +/// 書式が崩れた R/C 行 (2 トークン目が無い) を **明示的に reject** すること +/// (CodeRabbit #313: 旧実装は生トークンを残して coverage 不一致に頼っていたが、 +/// fail-closed の判定をパース時点に前倒しする)。 +#[test] +fn parse_summary_paths_rejects_malformed_rename_line() { + assert!( + parse_summary_paths("R docs/only-one-token.md\n").is_err(), + "new path (末尾トークン) が無い崩れた rename 行は Err" + ); + assert_eq!( + parse_summary_paths("R docs/a.md docs/b.md\n").expect("正常 rename は Ok"), + std::collections::BTreeSet::from(["docs/b.md".to_string()]), + "正常な rename は new path のみ" + ); +} + +/// CodeRabbit #313: valid 行と未知 status 行が混在した summary は、valid 行だけで +/// `expected` が非空になり coverage を通過してしまう per-line fail-open があった。 +/// 1 行でも解釈不能なら全体を Err にして fail-closed に倒す。 +#[test] +fn parse_summary_paths_rejects_mixed_valid_and_unparseable_lines() { + assert!( + parse_summary_paths("M docs/plan.md\nX src/foo.rs\n").is_err(), + "valid 行があっても未知 status 行が 1 つでもあれば Err (silent drop しない)" + ); +} + +/// run_diff の実経路で範囲不足が Error になること (report 経路まで含めた固定)。 +#[test] +fn run_diff_errors_when_coverage_check_fails() { + let out_path = std::env::temp_dir().join("test-run-diff-coverage.txt"); + let _ = std::fs::remove_file(&out_path); + let config = DiffConfig { + command: format!("echo {}", INCIDENT_TIP_ONLY_DIFF.lines().next().unwrap()), + output_path: out_path.to_string_lossy().into_owned(), + timeout: Some(30), + default_branch: None, + }; + + let result = run_diff_with(&config, &test_pr_range(), || { + Ok(INCIDENT_PR_SUMMARY.to_string()) + }); + + assert_eq!(result, DiffResult::Error); + assert!( + !out_path.exists(), + "範囲不足の diff はレビュー入力として書き出さない" + ); +} + +/// CodeRabbit #313: diff コマンドが空出力でも、PR 範囲に変更があれば (summary 非空) +/// coverage 検査で fail-closed にする。旧実装は `output.is_empty()` を coverage 検査 +/// より先に評価し、範囲がずれて空になった diff を「レビュー対象なし」と誤認して +/// gate を素通りさせていた。 +#[test] +fn run_diff_errors_when_output_empty_but_pr_range_has_changes() { + let out_path = std::env::temp_dir().join("test-run-diff-empty-but-changes.txt"); + let _ = std::fs::remove_file(&out_path); + let config = DiffConfig { + command: ZERO_BYTE_OUTPUT_CMD.to_string(), + output_path: out_path.to_string_lossy().into_owned(), + timeout: Some(30), + default_branch: None, + }; + + let result = run_diff_with(&config, &test_pr_range(), || { + Ok(INCIDENT_PR_SUMMARY.to_string()) + }); + + assert_eq!( + result, + DiffResult::Error, + "空出力でも PR 範囲に変更があれば coverage 検査で fail-closed になること" + ); + assert!( + !out_path.exists(), + "coverage 不足では diff ファイルを書き出さない" + ); +} + +/// 100 行を吐くコマンド。cmd.exe と POSIX sh で構文が非互換なため OS 別に +/// 出し分ける (WP-15)。POSIX 側は `seq` 不在の最小環境でも動く while ループ。 +#[cfg(windows)] +const EMIT_100_LINES_CMD: &str = "for /L %i in (1,1,100) do @echo line %i"; +#[cfg(not(windows))] +const EMIT_100_LINES_CMD: &str = "i=1; while [ $i -le 100 ]; do echo line $i; i=$((i+1)); done"; + +/// 何も出力せず正常終了するコマンド (0 バイト出力の検証用)。 +#[cfg(windows)] +const ZERO_BYTE_OUTPUT_CMD: &str = "type nul"; +#[cfg(not(windows))] +const ZERO_BYTE_OUTPUT_CMD: &str = "true"; + +/// stderr へ出力してから非 0 で終わるコマンド (失敗診断の検証用)。 +#[cfg(windows)] +const STDERR_THEN_FAIL_CMD: &str = "echo boom 1>&2& exit /b 1"; +#[cfg(not(windows))] +const STDERR_THEN_FAIL_CMD: &str = "echo boom 1>&2; exit 1"; + +/// stdout と stderr の両方へ出しつつ正常終了するコマンド。 +/// stderr (jj の警告相当) が diff 本体に混ざらない契約の検証用。 +#[cfg(windows)] +const STDOUT_AND_STDERR_CMD: &str = "echo real diff& echo Concurrent modification 1>&2"; +#[cfg(not(windows))] +const STDOUT_AND_STDERR_CMD: &str = "echo real diff; echo Concurrent modification 1>&2"; + +#[test] +fn run_diff_cmd_captures_more_than_40_lines() { + let result = run_diff_cmd(EMIT_100_LINES_CMD, 30); + assert!(result.is_ok(), "command should succeed"); + let output = result.unwrap(); + let line_count = output.lines().count(); + assert!( + line_count > 40, + "expected >40 lines captured, got {}; run_diff_cmd must not apply the 40-line cap", + line_count + ); +} + +#[test] +fn run_diff_returns_empty_when_output_is_empty() { + let out_path = std::env::temp_dir().join("test-run-diff-empty.txt"); + let _ = std::fs::remove_file(&out_path); + + let config = DiffConfig { + command: ZERO_BYTE_OUTPUT_CMD.to_string(), + output_path: out_path.to_string_lossy().into_owned(), + timeout: None, + default_branch: None, + }; + + let result = run_diff_with(&config, &test_pr_range(), || Ok(String::new())); + + assert_eq!( + result, + DiffResult::Empty, + "run_diff must return Empty when the diff command produces empty output" + ); + assert!( + !out_path.exists(), + "output file must not be created for an empty diff" + ); +} + +/// T12: capture_diff_snapshot は成功時に stdout を Some で返す +/// (post-takt re-gate の pre/post 比較の材料)。 +#[test] +fn capture_diff_snapshot_returns_output_on_success() { + let config = DiffConfig { + command: "echo snapshot-content".to_string(), + output_path: String::new(), + timeout: None, + default_branch: None, + }; + let snap = capture_diff_snapshot(&config, &test_pr_range()).expect("成功時は Some"); + assert!( + snap.contains("snapshot-content"), + "stdout をそのまま返すこと: {:?}", + snap + ); +} + +/// T12: 取得失敗 (コマンド exit 非 0) は None を返す +/// (呼び出し側は None を fail-closed = 変化ありに倒す)。 +#[test] +fn capture_diff_snapshot_returns_none_on_failure() { + let config = DiffConfig { + command: STDERR_THEN_FAIL_CMD.to_string(), + output_path: String::new(), + timeout: Some(30), + default_branch: None, + }; + assert!( + capture_diff_snapshot(&config, &test_pr_range()).is_none(), + "失敗時は None (呼び出し側で fail-closed に扱う)" + ); +} + +/// T6 回帰テスト群: diff stage に timeout が無く無限ハングし得た不具合 +/// (ADR-049 の流儀: 1 test = 1 failure mode + good/bad)。 +/// +/// 由来: 2026-07-16 の push パイプライン調査 (コード監査で発見。T5 と同じく +/// in the wild の発火記録は無く、「他 stage は全て timeout 付き = diff だけが穴」 +/// という非対称として特定された)。 +/// +/// 事故の形: `run_diff_cmd` は `Command::output()` で子プロセスの終了を**無限に** +/// 待っていた。ADR-045 の並列 workspace 運用で jj の lock 競合が起きると +/// `pnpm push` は診断も timeout も無いまま停止し、ユーザーは手動 kill するしかない。 +/// +/// 修正の核心は「timeout 付きで待ち、超過時は Err → `DiffResult::Error` = exit 5 で +/// 中断する (fail-closed / ADR-043)」。あわせて、判定に使う stdout を stderr と +/// 混ぜない契約 (レビュー対象を汚さない) も本 mod で seal する。 +mod t6_diff_timeout { + use super::*; + use std::time::{Duration, Instant}; + + /// 実行し続けるコマンド (ハングした jj の代役)。timeout が無ければ約 9s 待たされる。 + /// cmd.exe と POSIX sh で構文が非互換なため OS 別に出し分ける (WP-15)。 + /// 所要時間を両 OS で揃えないと片側だけ timeout 経路を検証しない穴になる。 + #[cfg(windows)] + const HANGING_COMMAND: &str = "ping 127.0.0.1 -n 10"; + #[cfg(not(windows))] + const HANGING_COMMAND: &str = "sleep 10"; + + const SHORT_TIMEOUT_SECS: u64 = 1; + + /// incident 再現 (bad): 応答しないコマンドを **timeout で打ち切る**こと。 + /// 修正前は `Command::output()` が返るまで待ち続け、本 assert には到達しなかった。 + #[test] + fn hanging_command_times_out_instead_of_waiting_forever() { + let started = Instant::now(); + let result = run_diff_cmd(HANGING_COMMAND, SHORT_TIMEOUT_SECS); + let elapsed = started.elapsed(); + + let err = result.expect_err("timeout は Err で返ること (無限待ちしない)"); + assert!( + err.contains("タイムアウト"), + "timeout と判る診断を返すこと: {:?}", + err, + ); + assert!( + elapsed < Duration::from_secs(5), + "timeout ({}s) 後すぐ制御を返すこと。{:?} 掛かった = コマンドの自然終了を\ + 待っている (T6 の不具合)", + SHORT_TIMEOUT_SECS, + elapsed, + ); + } + + /// timeout の診断は原因調査に足りること: 超過秒数と実行コマンドを含む。 + #[test] + fn timeout_error_reports_the_limit_and_the_command() { + let err = run_diff_cmd(HANGING_COMMAND, SHORT_TIMEOUT_SECS) + .expect_err("timeout は Err で返ること"); + assert!( + err.contains(&format!("{}s", SHORT_TIMEOUT_SECS)) && err.contains(HANGING_COMMAND), + "超過秒数と実行コマンドを診断に含めること: {:?}", + err, + ); + } + + /// timeout は fail-closed で pipeline を止めること (ADR-043)。 + /// `DiffResult::Error` は main.rs で exit 5 = 中断になる。空 diff 扱いで + /// **レビューを skip したまま push に進んではならない**。 + #[test] + fn timeout_aborts_the_pipeline_and_writes_no_diff_file() { + let out_path = std::env::temp_dir().join("test-run-diff-timeout.txt"); + let _ = std::fs::remove_file(&out_path); + + let config = DiffConfig { + command: HANGING_COMMAND.to_string(), + output_path: out_path.to_string_lossy().into_owned(), + timeout: Some(SHORT_TIMEOUT_SECS), + default_branch: None, + }; + + assert_eq!( + run_diff_with(&config, &test_pr_range(), || Ok(String::new())), + DiffResult::Error, + "timeout は Error (= exit 5 で中断) になること。Empty だとレビューを\ + skip して push に進んでしまう", + ); + assert!( + !out_path.exists(), + "timeout 時に diff ファイルを書かないこと (古い/欠けた diff でレビューさせない)", + ); + } + + /// good: timeout 内に終わるコマンドを誤って打ち切らないこと。 + #[test] + fn command_within_the_timeout_succeeds() { + let output = run_diff_cmd("echo diff line", 30).expect("即終了するコマンドは Ok"); + assert!(output.contains("diff line"), "stdout を返すこと: {:?}", output); + } + + /// `[diff] timeout` 未指定なら既定値が使われること (既定値の適用漏れ防止)。 + #[test] + fn absent_config_timeout_falls_back_to_the_default() { + let config = DiffConfig { + command: "echo ok".to_string(), + output_path: std::env::temp_dir() + .join("test-run-diff-default-timeout.txt") + .to_string_lossy() + .into_owned(), + timeout: None, + default_branch: None, + }; + assert_eq!( + config.timeout.unwrap_or(DEFAULT_DIFF_TIMEOUT_SECS), + DEFAULT_DIFF_TIMEOUT_SECS, + ); + assert_eq!(run_diff_with(&config, &test_pr_range(), || Ok(String::new())), DiffResult::HasContent); + let _ = std::fs::remove_file(&config.output_path); + } + + /// stderr を stdout に混ぜないこと: stdout は reviewers が読む diff そのものとして + /// ファイルに書かれるため、jj の警告 (並列 workspace 時の `Concurrent modification + /// detected` 等) が混入するとレビュー対象を汚す。`run_cmd_shell_*` (全 variant が + /// stdout/stderr を結合する) に載せ替えるとこのテストが落ちる。 + #[test] + fn stderr_is_not_merged_into_the_diff_output() { + let output = run_diff_cmd(STDOUT_AND_STDERR_CMD, 30).expect("exit 0 なら Ok"); + assert!(output.contains("real diff"), "stdout は残ること: {:?}", output); + assert!( + !output.contains("Concurrent modification"), + "stderr の警告が diff 内容に混入しないこと: {:?}", + output, + ); + } + + /// 失敗時は stderr を診断として返すこと (従来契約の維持)。 + #[test] + fn failure_returns_stderr_as_the_diagnostic() { + let err = run_diff_cmd(STDERR_THEN_FAIL_CMD, 30).expect_err("exit 1 は Err"); + assert!(err.contains("boom"), "stderr を診断に返すこと: {:?}", err); + } +} diff --git a/src/cli-push-runner/src/stages/docs_only_routing.rs b/src/cli-push-runner/src/stages/docs_only_routing.rs index 4e188c61..e03cd349 100644 --- a/src/cli-push-runner/src/stages/docs_only_routing.rs +++ b/src/cli-push-runner/src/stages/docs_only_routing.rs @@ -16,11 +16,16 @@ //! //! ## 判定範囲は PR 範囲 (`..@`)、単一コミット (`@`) ではない //! -//! `[diff]` stage は `jj diff -r @` (単一コミット) を使うが、本 stage は -//! `pr_size_check` と同じ `..@` (PR 範囲) を使う。quality_gate は -//! working copy 全体をビルド・テストするので、判定すべきは「push される差分全体が -//! docs-only か」であり、`@` 単独が docs-only でも祖先コミットが Rust に触れていれば -//! gate は必要。単一コミット判定は祖先の code 変更を見逃す穴になる。 +//! quality_gate は working copy 全体をビルド・テストするので、判定すべきは +//! 「push される差分全体が docs-only か」であり、`@` 単独が docs-only でも祖先 +//! コミットが Rust に触れていれば gate は必要。単一コミット判定は祖先の code 変更を +//! 見逃す穴になる。 +//! +//! 範囲は `Config::docs_only_pr_range()` から受け取る。かつては本 stage だけが +//! PR 範囲で `[diff]` stage は `jj diff -r @` (単一コミット) という**非対称**があり、 +//! 祖先コミットが AI レビューを一度も経ずに merge される欠陥が 4 回再発した +//! (todo 順位 288)。現在は `diff` / `docs_only_routing` / `pr_size_check` の 3 stage が +//! 同一の解決 (`Config::resolve_base_branch`) を共有し、非対称を構造的に排除している。 //! //! ## 由来 //! @@ -57,7 +62,10 @@ pub(crate) enum RoutingDecision { /// /// ADR-039 § Config opt-in: section 不在 / `enabled != Some(true)` は完全 skip /// (= 空 Vec を返し従来挙動)。明示的に `enabled = true` のときのみ判定する。 -pub(crate) fn run_docs_only_routing(config: Option<&DocsOnlyRoutingConfig>) -> Vec { +pub(crate) fn run_docs_only_routing( + config: Option<&DocsOnlyRoutingConfig>, + pr_range: &str, +) -> Vec { let Some(config) = config else { return Vec::new(); }; @@ -66,9 +74,8 @@ pub(crate) fn run_docs_only_routing(config: Option<&DocsOnlyRoutingConfig>) -> V } let override_active = std::env::var(OVERRIDE_ENV_VAR).ok().as_deref() == Some("1"); - let revset = format!("{}..@", config.effective_default_branch()); - let decision = decide_routing(config, override_active, || run_jj_diff_summary(&revset)); - log_and_map(decision, &revset) + let decision = decide_routing(config, override_active, || run_jj_diff_summary(pr_range)); + log_and_map(decision, pr_range) } /// 純関数の判定コア。jj 実行は `fetch_summary` closure で注入し (ADR-021 原則 3)、 @@ -132,9 +139,16 @@ fn log_and_map(decision: RoutingDecision, revset: &str) -> Vec { /// `jj diff --summary -r ''` を実行し stdout を返す。 /// /// pr_size_check の `run_jj_diff_stat` と同型 (drain_pipe_unlimited + timeout)。 -/// direct args のため `run_cmd_shell_*` (shell 経由) とは signature 非互換で -/// 共通化しない (ADR-044 層 1)。 -fn run_jj_diff_summary(revset: &str) -> Result { +/// +/// **direct args 必須** (shell 経由にしないこと): `[diff] command` のような shell 実行に +/// すると revset のクォートがシェル方言に依存する。cmd.exe は `-r "..@"` の +/// `"` を除去せず jj に渡すため `Revision '"..@"' doesn't exist` で必ず失敗する +/// (sh は除去するので Linux だけ通る = 片 OS でのみ壊れる形。2026-07-21 実測)。 +/// +/// `diff` stage の範囲カバレッジ検査もこの関数を使う。両者は「PR 範囲の変更ファイル +/// 一覧」という**同一の問い**を扱うため、別実装にすると本 PR が排除した非対称 +/// (stage ごとに違う範囲を見る) を再導入することになる。 +pub(super) fn run_jj_diff_summary(revset: &str) -> Result { let mut child = Command::new("jj") .args(["diff", "--summary", "-r", revset]) .stdout(Stdio::piped()) @@ -272,11 +286,11 @@ mod tests { #[test] fn disabled_config_returns_empty_skip_set() { let cfg = config(false, None); - assert!(run_docs_only_routing(Some(&cfg)).is_empty()); + assert!(run_docs_only_routing(Some(&cfg), "trunk()..@").is_empty()); } #[test] fn absent_config_returns_empty_skip_set() { - assert!(run_docs_only_routing(None).is_empty()); + assert!(run_docs_only_routing(None, "trunk()..@").is_empty()); } } diff --git a/src/cli-push-runner/src/stages/post_takt_regate.rs b/src/cli-push-runner/src/stages/post_takt_regate.rs index 44375dea..6538300e 100644 --- a/src/cli-push-runner/src/stages/post_takt_regate.rs +++ b/src/cli-push-runner/src/stages/post_takt_regate.rs @@ -86,8 +86,8 @@ fn decide_regate( /// takt 実行後の post-takt diff snapshot を取得する。`[diff]` 未設定時は None /// (呼び出し側で Indeterminate = fail-closed に倒れる)。 -fn fetch_post_diff(diff_config: Option<&DiffConfig>) -> Option { - diff_config.and_then(capture_diff_snapshot) +fn fetch_post_diff(diff_config: Option<&DiffConfig>, pr_range: &str) -> Option { + diff_config.and_then(|c| capture_diff_snapshot(c, pr_range)) } /// 判定結果を実行に写像する。skip 系は `true` (push 続行)、実行系は quality_gate の @@ -164,8 +164,9 @@ pub(crate) fn run_post_takt_regate(config: &Config, pre_diff: Option<&str>) -> R .is_some_and(|c| c.is_enabled()); let override_active = std::env::var(OVERRIDE_ENV_VAR).ok().as_deref() == Some("1"); + let pr_range = config.diff_pr_range(); let decision = decide_regate(enabled, override_active, pre_diff, || { - fetch_post_diff(config.diff.as_ref()) + fetch_post_diff(config.diff.as_ref(), &pr_range) }); let proceed = apply_regate_decision(decision, &config.quality_gate); @@ -295,8 +296,9 @@ command = "echo push" command: "echo unchanged".to_string(), output_path: String::new(), timeout: Some(30), + default_branch: None, }; - let pre = capture_diff_snapshot(&diff_cfg).expect("pre snapshot 取得"); + let pre = capture_diff_snapshot(&diff_cfg, "trunk()..@").expect("pre snapshot 取得"); let config = config_with(true, "exit 1", "echo unchanged"); let outcome = run_post_takt_regate(&config, Some(&pre)); diff --git a/src/cli-push-runner/src/stages/pr_size_check.rs b/src/cli-push-runner/src/stages/pr_size_check.rs index e9a4e0ca..df0926f9 100644 --- a/src/cli-push-runner/src/stages/pr_size_check.rs +++ b/src/cli-push-runner/src/stages/pr_size_check.rs @@ -24,7 +24,7 @@ use std::process::{Command, Stdio}; use crate::config::{ - PrSizeCheckConfig, DEFAULT_PR_SIZE_BASE_BRANCH, DEFAULT_PR_SIZE_BLOCK_THRESHOLD, + PrSizeCheckConfig, DEFAULT_PR_SIZE_BLOCK_THRESHOLD, DEFAULT_PR_SIZE_WARNING_THRESHOLD, }; use crate::log::{log_info, log_stage}; @@ -41,15 +41,14 @@ const OVERRIDE_ENV_VAR: &str = "PR_SIZE_CHECK_OVERRIDE"; /// /// fail-open: jj 不調 (timeout / 起動失敗 / 出力 parse 失敗) 時は warning ログのみで /// true を返し、push 自体は止めない。 -pub(crate) fn run_pr_size_check(config: Option<&PrSizeCheckConfig>) -> bool { +pub(crate) fn run_pr_size_check(config: Option<&PrSizeCheckConfig>, pr_range: &str) -> bool { let enabled = config.and_then(|c| c.enabled).unwrap_or(false); if !enabled { return true; } - let default_branch = effective_default_branch(config); let warning = effective_warning_threshold(config); let block = effective_block_threshold(config); - let revset = format!("{}..@", default_branch); + let revset = pr_range.to_string(); let stat_line = match run_jj_diff_stat(&revset) { Ok(line) => line, Err(e) => { @@ -122,14 +121,6 @@ fn classify_and_log( true } -fn effective_default_branch(config: Option<&PrSizeCheckConfig>) -> String { - config - .and_then(|c| c.default_branch.as_ref()) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| DEFAULT_PR_SIZE_BASE_BRANCH.to_string()) -} - fn effective_warning_threshold(config: Option<&PrSizeCheckConfig>) -> usize { config .and_then(|c| c.warning_threshold) @@ -344,33 +335,6 @@ mod tests { assert!(classify_and_log(100, 800, 1500, &r, false)); } - #[test] - fn effective_default_branch_uses_default_when_none() { - assert_eq!(effective_default_branch(None), "master"); - } - - #[test] - fn effective_default_branch_uses_config_when_present() { - let c = PrSizeCheckConfig { - enabled: Some(true), - default_branch: Some("main".to_string()), - warning_threshold: None, - block_threshold: None, - }; - assert_eq!(effective_default_branch(Some(&c)), "main"); - } - - #[test] - fn effective_default_branch_falls_back_when_blank_string() { - let c = PrSizeCheckConfig { - enabled: Some(true), - default_branch: Some(" ".to_string()), - warning_threshold: None, - block_threshold: None, - }; - assert_eq!(effective_default_branch(Some(&c)), "master"); - } - #[test] fn effective_warning_threshold_default() { assert_eq!(effective_warning_threshold(None), 800); @@ -405,7 +369,7 @@ mod tests { #[test] fn run_pr_size_check_skips_when_section_absent() { - assert!(run_pr_size_check(None)); + assert!(run_pr_size_check(None, &fixture_revset("master"))); } #[test] @@ -416,7 +380,7 @@ mod tests { warning_threshold: None, block_threshold: None, }; - assert!(run_pr_size_check(Some(&c))); + assert!(run_pr_size_check(Some(&c), &fixture_revset("master"))); } #[test] @@ -427,7 +391,7 @@ mod tests { warning_threshold: None, block_threshold: None, }; - assert!(run_pr_size_check(Some(&c))); + assert!(run_pr_size_check(Some(&c), &fixture_revset("master"))); } #[test] diff --git a/templates/push-runner-config.toml b/templates/push-runner-config.toml index bf439c56..ce4aaea8 100644 --- a/templates/push-runner-config.toml +++ b/templates/push-runner-config.toml @@ -47,9 +47,24 @@ commands = ["pnpm build"] # ] [diff] -# jj 環境: "jj diff -r @" +# `{{PR_RANGE}}` は push-runner が `..@` に展開する。 +# top-level の `default_branch` で base branch を指定する (main 系なら "main")。 +# +# **exe と config は同時に配布すること** (ADR-051): プレースホルダ対応前の +# cli-push-runner.exe が本 config を読むと `{{PR_RANGE}}` がそのまま jj に渡り +# `Failed to parse revset: Syntax error` で exit 5 になる。`pnpm deploy:hooks` は +# exe と本 template を同時に配るため通常は整合するが、片方だけ手で置き換えないこと。 +# +# **revset を直書きしないこと**: `-r @` のように PR より狭い範囲を書くと祖先 +# コミットが AI レビュー未経由で merge される (upstream で 4 回再発した欠陥)。 +# 生成 diff が PR 範囲を網羅していない場合は exit 5 で中断する (fail-closed)。 +# +# `--git` (unified diff) 必須: 範囲カバレッジ検査がヘッダを読むほか、jj 既定形式は +# 色を落とすと `+`/`-` が消え LLM レビュアーが削除を追加と誤読する。 +# +# jj 環境: "jj diff --git -r {{PR_RANGE}}" # git 環境: "git diff origin/HEAD...HEAD" -command = "jj diff -r @" +command = "jj diff --git -r {{PR_RANGE}}" output_path = ".takt/review-diff.txt" # timeout: 未指定時は 60s。超過時は diff 取得失敗 = exit 5 で中断 (fail-closed)。 # 大 diff / 低速環境で誤 timeout する場合のみ延長する (例: timeout = 180)。