From f25b1297a2d0c1969cd1578aa7f138625b887dc0 Mon Sep 17 00:00:00 2001 From: aloekun Date: Thu, 14 May 2026 17:27:16 +0900 Subject: [PATCH 1/3] =?UTF-8?q?docs(llm-offload):=20Phase=20D=20Round=202?= =?UTF-8?q?=20=E5=AE=8C=E9=81=82=E7=8A=B6=E6=B3=81=E3=82=92=20analysis.md?= =?UTF-8?q?=20/=20outcomes.md=20=E3=81=AB=E5=8F=8D=E6=98=A0=20(D-7=20#154?= =?UTF-8?q?=20=E7=B5=90=E6=9E=9C=E8=BE=BC=E3=81=BF)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/local-llm-offload-analysis.md | 21 +++++----- docs/local-llm-offload-phase-d-outcomes.md | 47 +++++++++++++++++----- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/docs/local-llm-offload-analysis.md b/docs/local-llm-offload-analysis.md index e2fea40..5b0ef15 100644 --- a/docs/local-llm-offload-analysis.md +++ b/docs/local-llm-offload-analysis.md @@ -213,21 +213,18 @@ cargo test -p cli-finding-classifier --test lint_screen_evals -- \ | 🛠️ D 前提整備 (順位 109) | ✅ 完了 | #144 (2026-05-11) | `.takt/lint-screen-report.md` に `## Diagnostic` section、real pipeline 経由で warn log が visible | | 🔄 D Round 1 (D-1〜D-3) | ✅ 完遂 | #145/#146/#147/#148 (2026-05-12) | env var override (順位 115) 解消、D-3 で初の real lint_screen 観測 = 1 data point | | 🔄 D Round 2 (D-4〜D-6) | ✅ 完遂 | #150/#151/#152 (2026-05-13) | size ramp-up + verdict variance 観測、累積 6 data points / 4 PR | -| 🔄 D Round 2 (D-7) | 🚧 進行中 | (Bundle c-1) | cli-merge-pipeline pre-emptive marker + Drop guard / orphan reaper / ADR-030 spec | +| 🔄 D Round 2 (D-7) | ✅ 完遂 | #154 (2026-05-14) | Bundle c-1 (順位 63/64/67): pre-emptive marker + RAII Drop guard + orphan reaper + ADR-030 §L1/L2 spec (self-dogfood で recovery 実証) | -**Phase E 着手前提条件 = 3-5 PR 累積 dogfood は D-6 完遂時点で既に充足** (4 PR / 6 data points)。D-7 完遂後に Phase E (採否判定) 移行可能。Round 1/2 で観測した **false positive 5 件中 5 件が file-type / scope 混同型** (mistral:7b の context window 内 hook source 周辺 hallucinate) → Bundle k 順位 123 (MD 除外フィルター) の構造的解消対象。詳細 metrics・観測の意義・各 PR の dogfood outcome は [phase-d-outcomes.md](local-llm-offload-phase-d-outcomes.md) 参照。 +**Phase E 着手前提条件 = 3-5 PR 累積 dogfood は完全充足** (5 PR / 7 data points、Round 1: D-3 + Round 2: D-4〜D-7)。Round 1/2 で観測した **false positive 5 件中 5 件が file-type / scope 混同型** (mistral:7b の context window 内 hook source 周辺 hallucinate) → Bundle k 順位 123 (MD 除外フィルター) の構造的解消対象。D-7 で新たに **Ollama サーバ可用性軸の fallback** を観測 (1/7 = 14%、kill-switch 50% との距離は確保)。詳細 metrics・観測の意義・各 PR の dogfood outcome は [phase-d-outcomes.md](local-llm-offload-phase-d-outcomes.md) 参照。 -##### 🔄 Phase D Round 2 残: D-7 (Bundle c-1) +##### ✅ Phase D Round 2 完遂: D-4〜D-7 すべて land 済 -| Order | 構成 | Tier / Effort | 推定 diff 行 | Diff Profile | -|---|---|---|---|---| -| **D-7** | Bundle c-1 (順位 63 + 64 + 67) = cli-merge-pipeline Drop guard / signal handler + orphan run reaper + ADR-030 spec amendment | T1×2 + T3 / M+M+XS | ~600-800 | Rust impl + signal trap + reaper logic + ADR markdown | - -**想定リスク (D-7)**: - -- **size 上限超過リスク**: M+M+XS が 800 行を超えた場合、c-1a (順位 63 単独) / c-1b (順位 64+67) の 2 PR 分割に switch (D-7 → D-7a/D-7b で 5 PR 拡張、Phase E 判定材料が 1 件増える方向で副次的に valid) -- **detail 見積もりの精度**: todo7.md (順位 63/64/67) の詳細を未参照、着手時に scope 修正の必要あり -- **num_ctx 32768 再 overflow**: D-3 (496 行) より大きい diff のため可能性あり、Phase A diagnostic log (`## Diagnostic` section) で即検知 +| Order | 構成 | Tier / Effort | 実 diff 行 | Diff Profile | PR | +|---|---|---|---|---|---| +| **D-4** ✅ | 順位 39 = takt workflow `model` 必須化 lint rule + CR Major fix 4 fields | T1 / S | ~340 行 | Rust lint rule + yaml multi-line regex + test infra | #150 | +| **D-5** ✅ | 順位 56 + 119 bundle = comment-lint test 拡充 + MAX cap test + UTF-8 boundary bug fix | T2+T2 / S+S | ~120 行 | Rust hook test infra + production bug fix | #151 | +| **D-6** ✅ | 順位 51 = `.takt/review-diff.txt` を fix→review iteration 間で refresh (案 C pivot) + Bundle k entry 登録 | T1 / M→S | ~80 + ~130 行 | takt facet instruction + design docs | #152 | +| **D-7** ✅ | Bundle c-1 (順位 63 + 64 + 67) = pre-emptive marker + Drop guard + orphan reaper + ADR-030 spec | T1×2 + T3 / M+M+XS | 実 845 ins / 175 del | Rust impl (cli-merge-pipeline + hooks-session-start) + ADR markdown | #154 | **Phase D 計測手順** (各 PR 共通): diff --git a/docs/local-llm-offload-phase-d-outcomes.md b/docs/local-llm-offload-phase-d-outcomes.md index 87dae43..31281e7 100644 --- a/docs/local-llm-offload-phase-d-outcomes.md +++ b/docs/local-llm-offload-phase-d-outcomes.md @@ -85,9 +85,9 @@ Phase C fix + Phase D 前提整備 (順位 109) 完了で **real pipeline 経由 4. **1 false positive は Phase b' agreement 75% (= 25% disagreement) と整合**: 想定範囲内、複数 PR 累積評価が前提 5. **副産物 (D-3 post-merge-feedback)**: `MAX_CUSTOM_VIOLATIONS` outer/inner loop break scope の explicit test 必要性を発見 (Tier 2-1 採用、順位 119)、rule⑧ への paths filter 適用範囲検討を順位 118 として backlog 化 -## 🔄 Phase D Round 2 進行中 (D-4〜D-7、2026-05-13〜) +## ✅ Phase D Round 2 完遂 (D-4〜D-7、2026-05-13〜2026-05-14) -Round 1 で実 dogfood data point が **1 件のみ** (D-3) に留まり、ADR-038 採用条件「5 PR 以上」+ analysis.md「3-5 PR 累積」前提との乖離が判明。**残 4 PR で Rust code 中心 + size ramp-up + 累積 5 PR (D-3 + D-4〜D-7) 達成** を狙う延長計画を策定 (D-1 反省 = docs-only 回避 / workflow gap 解消済確認)。 +Round 1 で実 dogfood data point が **1 件のみ** (D-3) に留まり、ADR-038 採用条件「5 PR 以上」+ analysis.md「3-5 PR 累積」前提との乖離が判明。**残 4 PR で Rust code 中心 + size ramp-up + 累積 5 PR (D-3 + D-4〜D-7) 達成** を狙う延長計画を策定 (D-1 反省 = docs-only 回避 / workflow gap 解消済確認)。**全 4 PR (D-4〜D-7) が land し、累積 7 data points / 5 PR で ADR-038 採用条件を完全充足**。 ### Round 2 対象 PR 構成 @@ -96,7 +96,7 @@ Round 1 で実 dogfood data point が **1 件のみ** (D-3) に留まり、ADR-0 | **D-4** ✅ | 順位 39 単独 = takt workflow `model` 必須化 lint rule + 副次作業 + CR Major fix で 4 fields 追加 | T1 / S | 実 ~340 行 (commit 0c2cc07d + 1ec15686) | Rust lint rule (yaml multi-line regex) + 6+1 unit tests + custom-lint-rules.toml entry + 3 yaml site touch | **PR #150 merged 2026-05-13、初 real lint_screen 観測 2 data points** | | **D-5** ✅ | 順位 56 + 119 bundle = comment-lint hook test 拡充 + `MAX_CUSTOM_VIOLATIONS` test + 副産物 `byte_offset_to_line` char-boundary panic bug fix | T2+T2 / S+S | 実 ~120 行 | comment-lint-rust + post-tool-linter test infra (UTF-8 5 + block boundary 6 + multi-rule MAX cap 2 + direct unit 1) | **PR #151 merged 2026-05-13、2 push events で 2 data points** | | **D-6** ✅ | 順位 51 単独 = `.takt/review-diff.txt` を fix→review iteration 間で refresh (案 A takt hook 不可と判明 → 案 C fix.md instruction-level refresh に pivot) + Bundle k 順位 123-127 entry 登録 combined PR | T1 / M→S | 実 ~80 行 D-6 + Bundle k entry ~130 行 | takt facet instruction (markdown) + design docs (docs-only PR) | **PR #152 merged 2026-05-13、real lint_screen 1 data point** | -| **D-7** | Bundle c-1 (順位 63 + 64 + 67) = cli-merge-pipeline Drop guard / signal handler + orphan run reaper + ADR-030 spec amendment | T1×2 + T3 / M+M+XS | ~600-800 | Rust impl + signal trap + reaper logic + ADR markdown | 未着手 | +| **D-7** ✅ | Bundle c-1 (順位 63 + 64 + 67) = cli-merge-pipeline pre-emptive marker + RAII Drop guard + orphan run reaper + ADR-030 §L1/L2 spec amendment | T1×2 + T3 / M+M+XS | 実 845 ins / 175 del | Rust impl (cli-merge-pipeline pre-emptive marker + FailedMarkerGuard + hooks-session-start ISO 8601 parser + orphan reaper + meta.json mutator) + ADR markdown amendment | **PR #154 merged 2026-05-14、self-dogfood で L1 recovery 機構が実証 + 新 failure mode (Ollama timeout) 観測** | **size ramp-up 設計 (Round 2)**: small → small-mid → mid → mid-large で num_ctx 32768 容量限界に向け漸増、各 size 帯で fallback 発生率 / Phase A diagnostic warn log 出力有無を観測。D-3 (mid, 496 行) と組合せて 5 size 帯をカバー。 @@ -154,16 +154,41 @@ D-6 は当初想定 (案 A = takt workflow hook) から **案 C = fix.md instruc 5. **設計判断 pivot (案 A→案 C) のメタ知見**: takt v0.35.3 schema を直接参照 (`piece-types.d.ts` / `runtime-environment.js`) して案 A 不可と確定 → advisor 相談で案 C 採用 (既存 Bundle Z #B-β `fix-metrics-check.ps1` invocation と同形 precedent)。framework capability の不確実性は **実装前の schema/source 直接確認** で解消可能 6. **副産物 (push workflow 知見)**: 2 つの独立タスク (Bundle k entry 登録 + D-6 impl) が working copy に混在した状態から `jj split` で 2 commit に分離 + 1 PR で push という pattern を実証 (PR 分割せず commit のみ分離) -## 📊 Phase D Round 1 + Round 2 (D-4/D-5/D-6) 完遂後の Phase E 判定材料 +### D-7 dogfood outcome (PR #154) -- ✅ pipeline integration works end-to-end (D-1 #144 smoke test + D-3 #148 + D-4 #150 + D-5 ×2 + **D-6 #152** で計 6 real diff 完走) -- ✅ num_ctx 32768 で 67-649 行 diff overflow なし (Phase C reference values と整合、D-5 docs-only 67 行 〜 D-5 impl 649 行で size 帯拡大、D-6 docs-only 210 行で再確認) -- ✅ fallback rate < 50% (D-3 0/1、D-4 initial 0/1、D-4 CR fix 0/1、D-5 ×2 0/2、**D-6 0/1** = 累積 0/6 = 0%) -- ⚠️ agreement: 累積 false positive **5 件観測** (D-3 / D-4 CR fix / D-5 ×2 / **D-6**) — いずれも `minor` severity で reviewer cross-check 通過、blocking なし。D-5 / D-6 観測で「diff 外 context から hallucinate する failure mode」が docs-only diff でも reproducible と確定 = Bundle k 順位 123 (MD 除外フィルター) の構造的解消対象 -- ✅ verdict variance: `auto_fix` (D-3 + D-4 CR fix + D-5 ×2 + D-6) と `informational` (D-4 initial) の 2 経路を観測 -- ✅ **累積 PR data 充足完了**: Round 1 (D-3) + Round 2 (D-4 + D-5 + D-6) で **6 data points (4 PR)** 取得済、累積 5 PR (= ADR-038 採用条件) は **4 PR 段階で先行充足**。残 D-7 で 5+ PR に到達予定 +D-7 は Bundle c-1 = post-merge-feedback workflow の abrupt termination 対策 3 件 (順位 63 pre-emptive marker + Drop guard / 順位 64 orphan reaper / 順位 67 ADR-030 §L1/L2 spec) を集約した Rust impl + ADR PR。pre-push-review APPROVE で 1 iter 完了、push event は 1 回のみ。**Round 2 最終 PR + 累積 5 PR 達成**: -Phase E 着手の前提条件 **3-5 PR 累積 dogfood** は D-6 完遂時点で **既に充足** (4 PR / 6 data points)。D-7 (Bundle c-1) 完遂後に Phase E (採否判定) に移行可能な状態。 +| Push event | commit | screen_decision | findings | fallback | num_ctx overflow | lint_screen latency | pipeline 総時間 | +|---|---|---|---|---|---|---|---| +| 初回 push (Bundle c-1 impl + ADR、~845 ins / 175 del = 実 net +710 行) | `da5d8ae2` | **`human_review`** (fallback path) | 0 | **あり (新 failure mode)** | なし (mistral:7b 到達前) | 測定不可 (HTTP timeout) | 757s (pre-push-review 6m 19s、1 iter で APPROVE) | + +**Fallback 詳細**: + +- `fallback_reason`: `ollama error: http: HTTP error: http://localhost:11434/api/generate: Network Error: ... (os error 10060)` +- 失敗層: HTTP 接続 (mistral:7b inference に到達せず) +- `## Diagnostic` section: 不在 (Phase A 診断 metadata は Ollama 応答から抽出するため、HTTP 失敗時は emit 不可) +- 設計通りの soft-fail: lint_screen が `human_review` で fallback、push pipeline は block せず完走 + +**D-7 観測の意義**: + +1. **新 failure mode 観測 (Ollama サーバ可用性)**: D-3〜D-6 で観測した 5 件はいずれも mistral:7b context window 内 hallucinate (file/scope 混同 FP) で同 root cause だったが、D-7 は **mistral:7b 到達前の HTTP 層 timeout** で異なる軸。Phase E 採否判定で「Ollama 可用性」という新軸を考慮する必要が顕在化 +2. **fallback path の運用 viability 実証**: pipeline がブロックされず completes (757s = D-6 と同等の所要時間)、reviewer (simplicity-review) も lint_screen 不在で独立に APPROVE 判定。soft-fail 設計が機能 +3. **Bundle c-1 self-dogfood 成功**: 本 PR がマージされた直後の post-merge-feedback workflow で **L1 pre-emptive marker が `.failed` として ~13 分間ディスクに visible**、UserPromptSubmit hook が正しく検出。workflow 完了 (`Workflow completed (2 iterations, 13m 22s)`) で `cleanup_failed_marker` により marker 削除 → Bundle c-1 の L1 floor が単体 test では捕捉できない full lifecycle で動作することを実証 +4. **post-merge-feedback 採用ゼロ + 5 件様子見 + 2 件却下**: aggregate-feedback agent が「PR #154 は L1 Drop guard + L2 orphan reaper の多層 recovery architecture を高品質な実装と十分なテストカバレッジを伴って land」と総評、提案項目 (panic unwind test / Ollama timeout test / ADR-024 amendment / global rule mirror / e2e integration test) はすべて即時実装義務なし +5. **累積 verdict variance の 3 経路化**: D-3〜D-6 で `auto_fix` (5) + `informational` (1) の 2 経路だったが、D-7 で `human_review` (via fallback) を追加観測 = lint_screen の判定空間 3 経路すべてカバー +6. **副産物 (workflow 知見)**: `pnpm merge-pr` を bash `&` で background 化したとき、bash subshell が即 exit 0 を返すため Claude Code 側の task 完了通知は merge 終了より早く来る。長時間 subprocess の正確な完了検知には Monitor + tail -f + meta.json status 監視を併用する pattern が有効 + +## 📊 Phase D Round 1 + Round 2 (D-4〜D-7) 完遂後の Phase E 判定材料 + +- ✅ pipeline integration works end-to-end (D-1 #144 smoke test + D-3 #148 + D-4 #150 + D-5 ×2 + D-6 #152 + **D-7 #154** で計 7 real diff 完走) +- ✅ num_ctx 32768 で 67-649 行 diff overflow なし (Phase C reference values と整合、D-5 docs-only 67 行 〜 D-5 impl 649 行で size 帯拡大、D-6 docs-only 210 行で再確認、D-7 は HTTP 層失敗で mistral:7b 未到達のため num_ctx 軸の観測対象外) +- ⚠️ fallback rate: D-3 0/1、D-4 initial 0/1、D-4 CR fix 0/1、D-5 ×2 0/2、D-6 0/1、**D-7 1/1 (新 failure mode = Ollama HTTP timeout)** = 累積 **1/7 ≈ 14%**。kill-switch 50% 閾値との距離は十分確保、ただし新軸 (サーバ可用性) を Phase E で評価する必要あり +- ⚠️ agreement: 累積 false positive **5 件観測** (D-3 / D-4 CR fix / D-5 ×2 / D-6) — いずれも `minor` severity で reviewer cross-check 通過、blocking なし。D-5 / D-6 観測で「diff 外 context から hallucinate する failure mode」が docs-only diff でも reproducible と確定 = Bundle k 順位 123 (MD 除外フィルター) の構造的解消対象。D-7 は fallback path のため FP 観測機会なし (findings 0) +- ✅ verdict variance: `auto_fix` (D-3 + D-4 CR fix + D-5 ×2 + D-6) + `informational` (D-4 initial) + **`human_review` via fallback (D-7)** の **3 経路すべて**を観測、判定空間カバレッジ完成 +- ✅ **累積 PR data 充足完了**: Round 1 (D-3) + Round 2 (D-4 + D-5 + D-6 + **D-7**) で **7 data points (5 PR)** 取得済、累積 5 PR (= ADR-038 採用条件) を **完全充足** +- ✅ **Bundle c-1 self-dogfood 成功**: D-7 自身のマージ過程で post-merge-feedback workflow の L1 pre-emptive marker + Drop guard が機能 (`Workflow completed (2 iterations, 13m 22s)` + 正常 path で marker cleanup) → 単体 test では捕捉できない full lifecycle で recovery 機構を実証 + +Phase E 着手の前提条件 **3-5 PR 累積 dogfood** は **完全充足** (5 PR / 7 data points)。Phase E (採否判定) に移行可能な状態。判定時の主要観察軸: (a) pipeline 機能性 ✅、(b) FP rate / 構造的解消可能性 ⚠️ (Bundle k 順位 123 で対処予定)、(c) verdict variance / fallback 設計 ✅、(d) **新軸 Ollama 可用性** ⚠️ (D-7 で 14% 観測、運用上の許容範囲評価が必要)。 ## 📝 Dogfood signal log (旧 PR roster の preview 結果、Phase B/D 比較対象) From 85461e4b3a5d485098d99b14bd269b35b6db9511 Mon Sep 17 00:00:00 2001 From: aloekun Date: Thu, 14 May 2026 17:27:16 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(lint-screen):=20Bundle=20k-1=20(?= =?UTF-8?q?=E9=A0=86=E4=BD=8D=20123)=20=E2=80=94=20Markdown=20=E3=83=8F?= =?UTF-8?q?=E3=83=B3=E3=82=AF=E9=99=A4=E5=A4=96=E3=83=95=E3=82=A3=E3=83=AB?= =?UTF-8?q?=E3=82=BF=E3=83=BC=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase D dogfood で 5 PR 連続観測された false positive (mistral:7b が docs-only diff や `.md` ファイルに対して Rust の `unused-import` を hallucinate) を 拡張子ベースの mechanical filter で構造的に解消する。 ## 設計 - `src/cli-push-runner/src/stages/lint_screen.rs` に `filter_excluded_hunks` 関数を追加 - `EXCLUDED_EXTENSIONS = ["md", "markdown"]` を hardcode (大文字小文字無視) - `diff --git ` 行を file-diff 境界として 1 ハンク = 1 chunk に分割、各 chunk の `+++ b/` (または `--- a/`) から拡張子を抽出して判定 - 全ハンクが対象外拡張子 (= docs-only diff) の場合は `FilterResult::AllExcluded` を 返し、invoke を完全に skip + skip-report (`screen_decision: skipped` + 理由) を書き出す short-circuit path に分岐 ## なぜ filter 適用箇所を (b) lint_screen stage 内にしたか - (a) `.takt/review-diff.txt` 生成時に drop すると review 全体の汎用性を壊す - (c) prompt 内で「.md は無視せよ」と instruct は LLM 信頼で危険 - (b) lint_screen stage 限定で副作用最小、Bundle k 順位 123 計画通り ## なぜ `run_lint_screen` を `invoke_and_write_report` に分割したか filter 経路追加で 50 行ガイドラインを超過 (58 行)。invoke + write_report の 2 step を `invoke_and_write_report` ヘルパーに切り出して 30 行台に収めた。 ## テストカバレッジ (10 件新規) - Rust-only / mixed (Rust + .md) / pure .md / pure .markdown - 大文字 (.MD / .Markdown) も除外対象に含む - `something.mdxyz.rs` のような中途 `.md` を含む path は除外しない - `/dev/null` create / delete の片側 path 判定 - 3-file mixed diff で hunk 境界が正しく保たれる - skip-report 本文に "skipped" / "docs-only diff" / "Bundle k 順位 123" を含む ## 累積 false positive への効果見積 5 観測のうち: - D-4 CR fix (TOML) — scope 外 (TOML は除外対象に含まない) = 残存 - D-5 ×2 + D-6 (Markdown / docs-only) — **構造的解消** (3 件消滅) - D-5 初回 (Rust + docs mixed) — Rust 部分は依然 mistral:7b に渡る = scope 内 Rust FP が残る可能性 期待: Phase E 採否判定前の FP rate を 5/7 → ~2/7 に低減。残 2 件は別 root cause (Rust scope hallucinate / TOML hallucinate) のため別途分析が必要。 Refs: Bundle k 順位 123 (todo8.md 削除済)、ADR-038 Known failure mode、 docs/local-llm-offload-phase-d-outcomes.md L162 (false positive 累積観測) --- docs/todo-summary.md | 1 - docs/todo8.md | 31 -- src/cli-push-runner/src/stages/lint_screen.rs | 287 +++++++++++++++++- 3 files changed, 281 insertions(+), 38 deletions(-) diff --git a/docs/todo-summary.md b/docs/todo-summary.md index c80cfdd..f1a6884 100644 --- a/docs/todo-summary.md +++ b/docs/todo-summary.md @@ -74,7 +74,6 @@ | 120 | 💎 Tier 3 | **`takt-workflow-persona-without-model` rule コメント拡張 + ADR-007 case study 追記 (PR #150 T1-#1 採用、実体 Tier 3 — analyzer 誤分類)** | todo8.md | XS | なし (実体は docs/comment 修正のみで mechanical enforcement なし → Tier 3 reclassify。次回 takt yaml schema 拡張時の rule 更新フロー文書化 + enumeration-based pattern の case study 記録、Tier 1 #2 ADR case study と同 PR で land 効率的) | | 121 | 🔧 Tier 2 | **`takt_workflow_persona_detects_required_permission_mode_violation` doc 修正 + 残り 3 fields 個別 fixture test 追加 (PR #150 T2-#1 採用)** | todo8.md | S | なし (4 fields のうち 3 fields は個別 fixture test 不在、doc comment と実態乖離。pass_previous_response / output_contracts / parallel の individual test を追加して将来 regex 変更時の regression 検知を担保) | | 122 | 💎 Tier 3 | **`development-workflow.md` Step 0 に「新 todo 着手前の既実装確認」チェックステップ追加 (PR #150 T3-#1 採用)** | todo8.md | XS | なし (memory rule `feedback_verify_task_not_already_done.md` を canonical workflow へ昇格。`jj log --limit 20 ` は決定的コマンドのため `feedback_no_unenforced_rules.md` 例外 = 既存実践の明文化 + 機械実行可能で採用、グローバル設定変更前に `~/.claude/` バックアップ取得必須) | -| 123 | 🚀 Tier 1 | **lint-screen の Markdown ファイル除外フィルター追加 (PR #151 T1-#2 採用、PR #152 で再観測) ★ Bundle k** | todo8.md | M | なし (D-3/D-4/D-5/**D-6** の 4 PR で一貫観測した `.md` への `unused-import` hallucinate FP を構造的に解消。Phase D dogfood 観測から導かれた最も価値ある決定論的防止策、拡張子ベース mechanical filter で実装可能、Frequency High + Adoption Risk None) | | 124 | 🚀 Tier 1 | **`no-ephemeral-todo-reference` rule の TOML positive test 追加 (PR #151 T1-#1 採用、PR #152 で再観測)** | todo8.md | S | なし (extensions 拡張が複数 PR にわたり反復する pattern (yaml/yml = PR #110、toml 等)、test gap 累積を構造的に防ぐ。Frequency Medium で採用基準を満たす) | | 125 | 🔧 Tier 2 | **UTF-8 マルチバイト boundary test を他の string-processing hooks に横展開 (PR #151 T2-#1 採用)** | todo8.md | M | なし (PR #151 で `byte_offset_to_line` char-boundary panic bug を test 拡充で発見、同型関数を持つ他 hooks に systemic 防御を確保。test 拡充が production fault detection に直結する事例の横展開) | | 126 | 💎 Tier 3 | **ADR-038 に mistral:7b 「diff 外 context hallucinate」failure mode を追記 (PR #151 T3-#1 採用、順位 123 と同 PR 推奨、PR #152 / PR #153 で再観測 = 5 PR 連続)** | todo8.md | XS | なし (**5 PR 連続観測** = High freq の failure mode を ADR codify、Phase b' fixture では再現しない pattern のため永続記録の価値あり、順位 123 と同 PR で実装と仕様の整合性確保。PR #153 T3-#1 では root cause + structural fix の両方を明記する要件追加) | diff --git a/docs/todo8.md b/docs/todo8.md index 201db48..9cb5ba9 100644 --- a/docs/todo8.md +++ b/docs/todo8.md @@ -160,37 +160,6 @@ --- -### lint-screen の Markdown ファイル除外フィルター追加 (PR #151 T1-#2 採用、Bundle k、**PR #152 で再観測**) - -> **動機**: PR #148 (D-3) / PR #150 (D-4 CR fix) / PR #151 (D-5) / **PR #152 (D-6 docs-only)** の 4 PR で「mistral:7b が docs-only diff や `.md` ファイルに対して Rust の `unused-import` を hallucinate する」false positive pattern が一貫して観測。特に PR #151 / PR #152 では docs-only diff でも同じ FP を再現 (PR #152 では `docs/local-llm-offload-analysis.md` 行 1 を `use std::io::Write;` と誤認)。**diff 内容ではなく hook source 周辺の context を見て hallucinate している強い証拠**。拡張子ベースの mechanical フィルタで diff 段階から `.md` ハンクを除外すれば、reviewer cross-check の負荷も軽減できる。 -> -> **本タスクの位置づけ**: PR #151 post-merge-feedback Tier 1 #2 採用 → PR #152 post-merge-feedback で Frequency High 閾値到達を再確認 (Severity Medium / Frequency High / Effort S / Adoption Risk None)。Phase D dogfood 観測から導かれた最も価値ある決定論的防止策。Bundle k のコア。 -> -> **参照**: `.claude/feedback-reports/151.md` Tier 1 #2、`src/cli-push-runner/src/stages/lint_screen.rs`、D-3/D-4/D-5 outcome (`docs/local-llm-offload-analysis.md`) - -#### 設計決定の余地 - -- **filter 適用箇所**: (a) `.takt/review-diff.txt` 生成時に `.md` ハンクを drop / (b) lint_screen stage で diff parse 後にハンクを skip / (c) prompt 内で「.md は無視せよ」と instruct (= LLM 信頼、危険) -- **推奨は (b)**: diff 段階で `.md` 以外のハンクのみを mistral:7b に渡す。Rust 側で diff hunk header (`+++ b/path`) を parse して拡張子を判定、`.md` / `.markdown` を skip -- **fallback 経路**: 全 diff が `.md` のみだった場合は lint_screen 自体を skip + report に「`docs-only diff のため lint_screen はスキップしました`」を出力 - -#### 作業計画 - -- [ ] `src/cli-push-runner/src/stages/lint_screen.rs` に diff hunk filter 関数を追加 -- [ ] filter は `extensions_to_exclude = ["md", "markdown"]` を hardcode (将来 config 化検討) -- [ ] unit test: 純 .md diff / mixed (Rust + .md) diff / 純 Rust diff の 3 ケースで filter 動作 assert -- [ ] integration test: docs-only PR の dogfood シナリオで lint_screen が skip + warn を出すこと -- [ ] `.takt/lint-screen-report.md` 出力に skip 理由を明示 -- [ ] 本エントリ削除 + todo-summary.md 行削除 - -#### 完了基準 - -- 純 `.md` diff の lint_screen 起動時に Rust hallucinate FP が 0 件になる -- mixed diff でも `.md` 部分は無視され、Rust hunks のみが mistral:7b に渡る -- 既存 5 観測のうち D-4 CR fix (TOML)、D-5 ×2 (Markdown FP) は本フィルタで構造的に消滅 (D-3 globset FP は Rust scope なので残る = 期待動作) - ---- - ### `no-ephemeral-todo-reference` rule の TOML positive test 追加 (PR #151 T1-#1 採用、**PR #152 で再観測**) > **動機**: PR #151 の CodeRabbit nitpick (および本 PR で発見されなかった latent gap) で、`no-ephemeral-todo-reference` rule が TOML ファイルを extensions に持つ場合の positive test (= 実際に violation を検出することの assertion) が不在と判明。既存テスト `no_ephemeral_todo_self_exclusion_invariant_holds_on_deployed_toml` は self-exclusion 確認のみで、検出力の test ではない。 diff --git a/src/cli-push-runner/src/stages/lint_screen.rs b/src/cli-push-runner/src/stages/lint_screen.rs index f4fe674..526c7b2 100644 --- a/src/cli-push-runner/src/stages/lint_screen.rs +++ b/src/cli-push-runner/src/stages/lint_screen.rs @@ -59,7 +59,7 @@ pub(crate) fn run_lint_screen(config: &LintScreenConfig, diff_path: &str) { let started = Instant::now(); log_stage(STAGE, "実行中 (試験運用、エラーは skip + warn)"); - let diff = match read_diff(diff_path, config) { + let raw_diff = match read_diff(diff_path, config) { Ok(d) => d, Err(reason) => { log_stage(STAGE, &format!("skip: {}", reason)); @@ -67,8 +67,37 @@ pub(crate) fn run_lint_screen(config: &LintScreenConfig, diff_path: &str) { } }; + let output_path = config + .output_path + .as_deref() + .unwrap_or(DEFAULT_LINT_SCREEN_OUTPUT_PATH); + + let diff = match filter_excluded_hunks(&raw_diff) { + FilterResult::Kept(filtered) => filtered, + FilterResult::AllExcluded => { + log_stage( + STAGE, + "skip: docs-only diff (`.md`/`.markdown` のみ)、Bundle k 順位 123", + ); + write_skip_report_logged(output_path); + return; + } + }; + + invoke_and_write_report(config, output_path, &diff, started); +} + +/// classifier 呼び出し + report 書き出しを 1 ステップにまとめた helper。 +/// +/// `run_lint_screen` を 50 行ガイドラインに収めるための機能分離。 +fn invoke_and_write_report( + config: &LintScreenConfig, + output_path: &str, + diff: &str, + started: Instant, +) { let params = resolve_invoke_params(config); - let output = match invoke_classifier(¶ms, &diff) { + let output = match invoke_classifier(¶ms, diff) { Ok(o) => o, Err(reason) => { log_stage(STAGE, &format!("skip: classifier {}", reason)); @@ -76,10 +105,6 @@ pub(crate) fn run_lint_screen(config: &LintScreenConfig, diff_path: &str) { } }; - let output_path = config - .output_path - .as_deref() - .unwrap_or(DEFAULT_LINT_SCREEN_OUTPUT_PATH); match write_report(output_path, &output.stdout, &output.stderr) { Ok(()) => log_stage( STAGE, @@ -271,6 +296,109 @@ fn sanitize_cell(s: &str) -> String { s.replace('|', "\\|").replace('\n', " ") } +/// lint_screen の対象外とする拡張子 (lowercase で比較)。 +/// +/// 由来 (Bundle k 順位 123): mistral:7b が docs-only diff や `.md` ファイルに対して +/// Rust の `unused-import` を hallucinate する FP が PR #148/#150/#151/#152/#153 で +/// 5 PR 連続観測された。diff 段階で `.md` / `.markdown` ハンクを drop することで +/// この failure mode を構造的に解消する (ADR-038 §Known failure mode 参照)。 +const EXCLUDED_EXTENSIONS: &[&str] = &["md", "markdown"]; + +/// `filter_excluded_hunks` の戻り値。Markdown 100% の diff は invoke を完全に +/// skip して別 path (skip-report 書き出し + 短絡 return) に流す必要があるため、 +/// 通常 case (`Kept`) と区別する enum を返す。 +enum FilterResult { + Kept(String), + AllExcluded, +} + +/// 入力 diff から `EXCLUDED_EXTENSIONS` 拡張子のハンクを除外する。 +/// +/// 戻り値: +/// - `FilterResult::Kept(text)`: 1 件以上の対象外ハンクが残った場合、その diff text +/// - `FilterResult::AllExcluded`: 全ハンクが対象外拡張子だった (= docs-only diff) 場合 +/// +/// 実装方針: `diff --git ` 行を file-diff の境界として 1 ハンク = 1 chunk に分割、 +/// 各 chunk の `+++ b/` (なければ `--- a/`) から拡張子を取り出して判定。 +/// 拡張子は ASCII lowercase 比較 (= 大文字 `.MD` / `.Markdown` も除外対象に含む)。 +fn filter_excluded_hunks(raw_diff: &str) -> FilterResult { + let chunks = split_into_file_diffs(raw_diff); + if chunks.is_empty() { + return FilterResult::Kept(raw_diff.to_string()); + } + let kept: Vec<&str> = chunks + .iter() + .filter(|chunk| !chunk_has_excluded_extension(chunk)) + .copied() + .collect(); + if kept.is_empty() { + return FilterResult::AllExcluded; + } + FilterResult::Kept(kept.join("")) +} + +/// diff text を `diff --git ` 行を境界に file-diff chunks に分割する。 +/// +/// 行頭の `diff --git ` のみを境界とみなす。chunk 末尾は次の境界直前 (改行込み)。 +/// 入力が `diff --git ` で始まらない場合 (= unified diff fragment ではない可能性)、 +/// 空 vec を返して caller が原文 fallthrough する。 +fn split_into_file_diffs(raw_diff: &str) -> Vec<&str> { + if !raw_diff.starts_with("diff --git ") { + return Vec::new(); + } + let mut chunks = Vec::new(); + let mut chunk_start = 0; + for (idx, _) in raw_diff.match_indices("\ndiff --git ") { + let end = idx + 1; + chunks.push(&raw_diff[chunk_start..end]); + chunk_start = end; + } + chunks.push(&raw_diff[chunk_start..]); + chunks +} + +/// chunk 内の最初の `+++ b/` または `--- a/` から拡張子を抽出し、 +/// `EXCLUDED_EXTENSIONS` に該当すれば true を返す。 +fn chunk_has_excluded_extension(chunk: &str) -> bool { + let path = chunk + .lines() + .find_map(|line| { + line.strip_prefix("+++ b/") + .or_else(|| line.strip_prefix("--- a/")) + }) + .unwrap_or(""); + if path.is_empty() { + return false; + } + let ext = path.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + EXCLUDED_EXTENSIONS.contains(&ext.as_str()) +} + +/// `write_skip_report` を呼び出し、失敗時はステージログに記録する。 +/// +/// `run_lint_screen` のネスト深度を抑えるための分離 (match arm 内に if let を +/// 重ねないよう、エラー処理を 1 関数に閉じ込める)。 +fn write_skip_report_logged(output_path: &str) { + if let Err(e) = write_skip_report(output_path) { + log_stage(STAGE, &format!("skip: skip-report 書き出し失敗: {}", e)); + } +} + +/// 全ハンクが対象外拡張子だった場合に書き出す skip-report。invoke は完全に skip する。 +fn write_skip_report(output_path: &str) -> Result<(), String> { + let path = Path::new(output_path); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("ディレクトリ作成失敗: {}", e))?; + } + let body = format!( + "{}## Summary\n\n- screen_decision: `skipped`\n- 理由: docs-only diff のため lint_screen はスキップしました \ + (`.md` / `.markdown` 拡張子のみで Rust hallucinate FP を構造的に防止、Bundle k 順位 123 / ADR-038)\n\n\ + ## Findings\n\n(なし)\n", + REPORT_PREAMBLE + ); + std::fs::write(path, body).map_err(|e| format!("write: {}", e)) +} + #[cfg(test)] mod tests { use super::*; @@ -453,4 +581,151 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("空")); } + + fn rust_chunk(path: &str) -> String { + format!( + "diff --git a/{path} b/{path}\n\ + index abc..def 100644\n\ + --- a/{path}\n\ + +++ b/{path}\n\ + @@ -1,1 +1,1 @@\n\ + -old\n\ + +new\n", + path = path + ) + } + + fn md_chunk(path: &str) -> String { + format!( + "diff --git a/{path} b/{path}\n\ + index abc..def 100644\n\ + --- a/{path}\n\ + +++ b/{path}\n\ + @@ -1,1 +1,1 @@\n\ + -# heading\n\ + +# heading updated\n", + path = path + ) + } + + fn assert_kept(result: FilterResult) -> String { + match result { + FilterResult::Kept(text) => text, + FilterResult::AllExcluded => panic!("expected Kept, got AllExcluded"), + } + } + + #[test] + fn filter_excluded_hunks_keeps_rust_only_diff_unchanged() { + let diff = rust_chunk("src/lib.rs"); + let result = assert_kept(filter_excluded_hunks(&diff)); + assert_eq!(result, diff); + } + + #[test] + fn filter_excluded_hunks_drops_md_hunk_from_mixed_diff() { + let rust = rust_chunk("src/main.rs"); + let md = md_chunk("docs/README.md"); + let combined = format!("{}{}", rust, md); + let kept = assert_kept(filter_excluded_hunks(&combined)); + assert!(kept.contains("src/main.rs")); + assert!(!kept.contains("docs/README.md")); + } + + #[test] + fn filter_excluded_hunks_signals_all_excluded_for_pure_markdown_diff() { + let diff = format!( + "{}{}", + md_chunk("docs/a.md"), + md_chunk("docs/b.markdown") + ); + match filter_excluded_hunks(&diff) { + FilterResult::AllExcluded => {} + FilterResult::Kept(_) => panic!("expected AllExcluded for pure .md/.markdown diff"), + } + } + + #[test] + fn filter_excluded_hunks_treats_markdown_extension_case_insensitively() { + let diff = format!("{}{}", md_chunk("README.MD"), md_chunk("notes.Markdown")); + match filter_excluded_hunks(&diff) { + FilterResult::AllExcluded => {} + FilterResult::Kept(_) => panic!("uppercase .MD / mixed-case .Markdown must be excluded"), + } + } + + #[test] + fn filter_excluded_hunks_keeps_path_with_md_in_middle_not_extension() { + let diff = rust_chunk("src/something.mdxyz.rs"); + let kept = assert_kept(filter_excluded_hunks(&diff)); + assert_eq!(kept, diff); + } + + #[test] + fn filter_excluded_hunks_handles_non_diff_input_as_passthrough() { + let raw = "not a unified diff\njust raw text"; + let kept = assert_kept(filter_excluded_hunks(raw)); + assert_eq!(kept, raw); + } + + #[test] + fn filter_excluded_hunks_keeps_dev_null_create_path() { + let diff = "diff --git a/src/new.rs b/src/new.rs\n\ + new file mode 100644\n\ + index 0000000..1234567\n\ + --- /dev/null\n\ + +++ b/src/new.rs\n\ + @@ -0,0 +1,1 @@\n\ + +pub fn x() {}\n"; + let kept = assert_kept(filter_excluded_hunks(diff)); + assert!(kept.contains("src/new.rs")); + } + + #[test] + fn filter_excluded_hunks_excludes_dev_null_delete_of_md() { + let diff = "diff --git a/docs/old.md b/docs/old.md\n\ + deleted file mode 100644\n\ + index 1234567..0000000\n\ + --- a/docs/old.md\n\ + +++ /dev/null\n\ + @@ -1,1 +0,0 @@\n\ + -# removed\n"; + match filter_excluded_hunks(diff) { + FilterResult::AllExcluded => {} + FilterResult::Kept(_) => panic!( + "delete of .md file should be excluded (--- a/ path is .md, +++ is /dev/null)" + ), + } + } + + #[test] + fn filter_excluded_hunks_preserves_hunk_boundaries_for_three_file_mixed() { + let diff = format!( + "{}{}{}", + rust_chunk("src/a.rs"), + md_chunk("docs/b.md"), + rust_chunk("src/c.rs"), + ); + let kept = assert_kept(filter_excluded_hunks(&diff)); + assert!(kept.contains("src/a.rs")); + assert!(!kept.contains("docs/b.md")); + assert!(kept.contains("src/c.rs")); + let lines: Vec<&str> = kept.lines().filter(|l| l.starts_with("diff --git ")).collect(); + assert_eq!(lines.len(), 2, "exactly 2 diff --git boundaries must remain"); + } + + #[test] + fn write_skip_report_writes_explanatory_body() { + let path = std::env::temp_dir().join(format!( + "test-lint-screen-skip-report-{}.md", + std::process::id() + )); + let path_str = path.to_str().unwrap(); + write_skip_report(path_str).unwrap(); + let body = std::fs::read_to_string(&path).unwrap(); + assert!(body.contains("skipped")); + assert!(body.contains("docs-only diff")); + assert!(body.contains("Bundle k 順位 123")); + let _ = std::fs::remove_file(&path); + } } From bc3a4f2a70cf5916dbfdf8dad3d508b96877351d Mon Sep 17 00:00:00 2001 From: aloekun Date: Fri, 15 May 2026 12:09:20 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(lint-screen):=20CR=20#155=20Major=20?= =?UTF-8?q?=E2=80=94=20chunk=5Fhas=5Fexcluded=5Fextension=20=E3=81=A7=20++?= =?UTF-8?q?+=20b/=20(new=20path)=20=E3=82=92=E5=84=AA=E5=85=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit Major 指摘の rename 時除外漏れ bug を解消する。unified diff の慣例で `--- a/` が `+++ b/` より先に出現するため、find_map の旧実装では 両者を OR で取ると常に旧パスが優先されてしまう問題があった。 ## 修正前の bug ```rust let path = chunk.lines().find_map(|line| { line.strip_prefix("+++ b/") .or_else(|| line.strip_prefix("--- a/")) }).unwrap_or(""); ``` `*.rs → *.md` の rename で: - `--- a/src/a.rs` を先に処理 → `find_map` が "src/a.rs" を返す - `+++ b/docs/a.md` に到達せず - 拡張子 = "rs" → EXCLUDED_EXTENSIONS に含まれず → mistral:7b が `.md` 内容を見る - = Bundle k-1 の filter contract (新パス優先) に違反 ## 修正後 ```rust let new_path = chunk.lines().find_map(|line| line.strip_prefix("+++ b/")); let old_path = chunk.lines().find_map(|line| line.strip_prefix("--- a/")); let path = new_path.or(old_path).unwrap_or(""); ``` new_path を chunk 全体から先に探す → rename でも新拡張子で判定。new_path が無い 場合 (= `+++ /dev/null` の delete) のみ old_path にフォールバック。 ## テスト追加 (2 件) - `filter_excluded_hunks_prefers_b_path_on_rename_to_markdown`: `*.rs → *.md` rename が AllExcluded になることを assert - `filter_excluded_hunks_keeps_rename_from_md_to_rust`: 逆方向 (`*.md → *.rs`) rename が Kept になることを assert (symmetric 検証) ## 累積テスト cli-push-runner stages::lint_screen 24 → 26 件 pass、clippy clean、release exe deploy 済 Refs: PR #155 CR review コメント (Major 🟠 Quick win)、Bundle k-1 順位 123、ADR-038 --- src/cli-push-runner/src/stages/lint_screen.rs | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/src/cli-push-runner/src/stages/lint_screen.rs b/src/cli-push-runner/src/stages/lint_screen.rs index 526c7b2..9fa145f 100644 --- a/src/cli-push-runner/src/stages/lint_screen.rs +++ b/src/cli-push-runner/src/stages/lint_screen.rs @@ -357,16 +357,20 @@ fn split_into_file_diffs(raw_diff: &str) -> Vec<&str> { chunks } -/// chunk 内の最初の `+++ b/` または `--- a/` から拡張子を抽出し、 -/// `EXCLUDED_EXTENSIONS` に該当すれば true を返す。 +/// chunk 内の `+++ b/` (new path) を優先して拡張子を抽出する。 +/// new path が無い場合 (= delete 操作で `+++ /dev/null` のケース) のみ +/// `--- a/` (old path) にフォールバック。`EXCLUDED_EXTENSIONS` に +/// 該当すれば true を返す。 +/// +/// 新パス優先の根拠 (CR #155 Major 指摘): unified diff の慣例では `--- a/` +/// が `+++ b/` より先に出現するため、単純な `find_map` で両者を OR にすると +/// 旧パスが優先されてしまう。これだと `*.rs → *.md` の rename で **新パス側が `.md` +/// にも関わらず旧 `.rs` 拡張子で判定**され、Markdown 除外が機能しない bug が生じる。 +/// new path を chunk 全体から先に探し、無い場合のみ old path に落とす。 fn chunk_has_excluded_extension(chunk: &str) -> bool { - let path = chunk - .lines() - .find_map(|line| { - line.strip_prefix("+++ b/") - .or_else(|| line.strip_prefix("--- a/")) - }) - .unwrap_or(""); + let new_path = chunk.lines().find_map(|line| line.strip_prefix("+++ b/")); + let old_path = chunk.lines().find_map(|line| line.strip_prefix("--- a/")); + let path = new_path.or(old_path).unwrap_or(""); if path.is_empty() { return false; } @@ -681,6 +685,43 @@ mod tests { assert!(kept.contains("src/new.rs")); } + #[test] + fn filter_excluded_hunks_prefers_b_path_on_rename_to_markdown() { + let diff = "diff --git a/src/a.rs b/docs/a.md\n\ + similarity index 100%\n\ + rename from src/a.rs\n\ + rename to docs/a.md\n\ + --- a/src/a.rs\n\ + +++ b/docs/a.md\n\ + @@ -1,1 +1,1 @@\n\ + -old\n\ + +new\n"; + match filter_excluded_hunks(diff) { + FilterResult::AllExcluded => {} + FilterResult::Kept(_) => panic!( + "rename .rs -> .md must be excluded based on new path (CR #155 Major)" + ), + } + } + + #[test] + fn filter_excluded_hunks_keeps_rename_from_md_to_rust() { + let diff = "diff --git a/docs/old.md b/src/new.rs\n\ + similarity index 100%\n\ + rename from docs/old.md\n\ + rename to src/new.rs\n\ + --- a/docs/old.md\n\ + +++ b/src/new.rs\n\ + @@ -1,1 +1,1 @@\n\ + -old\n\ + +new\n"; + let kept = assert_kept(filter_excluded_hunks(diff)); + assert!( + kept.contains("src/new.rs"), + "rename .md -> .rs must be kept based on new path (symmetric to rename-to-md test)" + ); + } + #[test] fn filter_excluded_hunks_excludes_dev_null_delete_of_md() { let diff = "diff --git a/docs/old.md b/docs/old.md\n\