From 99695fa837bb453a3c83fe7f2f61c1f0085875dd Mon Sep 17 00:00:00 2001 From: Akio Jinsenji Date: Tue, 12 May 2026 00:11:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(reference):=20compute=5Fline=5Fdiff=20?= =?UTF-8?q?=E3=82=92=20LCS=20/=20Myers=20=E5=8C=96=EF=BC=88Phase=204-B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC #188 (Phase 3) で MVP として all-or-nothing 単一 hunk を返してい た compute_line_diff を similar クレートの TextDiff::from_slices ベ ースに置き換える。変更箇所のみを含む細粒度な Hunk のリストを返すよ うになり、reference diff の出力が局所化されて LLM context と人間レ ビューの両方で扱いやすくなる。 主な変更: - Cargo.toml に similar = "2" を追加 - src/reference/diff.rs::compute_line_diff: TextDiff::from_slices と iter_all_changes を歩く実装に置換。Insert / Delete の連続グループ を 1 Hunk にまとめ、Equal が来たら境界にする。before_start / after_start は変更開始行(1-indexed) - 既存テスト compute_line_diff_produces_single_hunk_for_changes を compute_line_diff_replacement_isolates_changed_lines に rename し て新挙動 (差分行のみ含む) に合わせる - 新規 RED テスト 3 件: inserts_only / deletes_only / separate_changes_produces_multiple_hunks - tasks/lessons.md に「2026-05-12 子 SPEC 増殖の防止」を追記して umbrella SPEC 下では新 SPEC を生やさないルールを言語化 ローカル検証: - cargo fmt -- --check: clean - cargo clippy --all-targets -- -D warnings: clean - cargo test --bin unity-cli: 382 passed / 0 failed - cargo llvm-cov --all-targets: TOTAL line coverage 90.94% - unity-cli skills lint --severity error: 15 skills / 0 violations Refs #191 Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 7 +++ Cargo.toml | 1 + src/reference/diff.rs | 113 ++++++++++++++++++++++++++++++++++++++---- tasks/lessons.md | 7 +++ 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b48e0e..4ca9567 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -945,6 +945,12 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "smallvec" version = "1.15.1" @@ -1201,6 +1207,7 @@ dependencies = [ "serde_json", "serde_yml", "sha2", + "similar", "tempfile", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 62f6673..dd3bbd5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ walkdir = "2.5" sha2 = "0.11" ureq = { version = "3.3", features = ["json"] } serde_yml = "0.0.12" +similar = "2" [dev-dependencies] tempfile = "3.27" diff --git a/src/reference/diff.rs b/src/reference/diff.rs index 2dc1e95..386a30c 100644 --- a/src/reference/diff.rs +++ b/src/reference/diff.rs @@ -61,12 +61,52 @@ pub fn compute_line_diff(before: &[String], after: &[String]) -> Vec { if before == after { return vec![]; } - vec![Hunk { - before: before.to_vec(), - after: after.to_vec(), - before_start: 1, - after_start: 1, - }] + let before_refs: Vec<&str> = before.iter().map(|s| s.as_str()).collect(); + let after_refs: Vec<&str> = after.iter().map(|s| s.as_str()).collect(); + let diff = similar::TextDiff::from_slices(&before_refs, &after_refs); + let mut hunks: Vec = Vec::new(); + let mut current: Option<(Vec, Vec, u32, u32)> = None; + let mut before_line = 1u32; + let mut after_line = 1u32; + + for change in diff.iter_all_changes() { + let value = change.value().to_string(); + match change.tag() { + similar::ChangeTag::Equal => { + if let Some((b, a, bs, as_)) = current.take() { + hunks.push(Hunk { + before: b, + after: a, + before_start: bs, + after_start: as_, + }); + } + before_line += 1; + after_line += 1; + } + similar::ChangeTag::Delete => { + let entry = current + .get_or_insert_with(|| (Vec::new(), Vec::new(), before_line, after_line)); + entry.0.push(value); + before_line += 1; + } + similar::ChangeTag::Insert => { + let entry = current + .get_or_insert_with(|| (Vec::new(), Vec::new(), before_line, after_line)); + entry.1.push(value); + after_line += 1; + } + } + } + if let Some((b, a, bs, as_)) = current.take() { + hunks.push(Hunk { + before: b, + after: a, + before_start: bs, + after_start: as_, + }); + } + hunks } pub fn compute_symbol_diff( @@ -282,15 +322,66 @@ mod tests { } #[test] - fn compute_line_diff_produces_single_hunk_for_changes() { + fn compute_line_diff_replacement_isolates_changed_lines() { let before = vec!["a".to_string(), "b".to_string()]; let after = vec!["a".to_string(), "c".to_string()]; let hunks = compute_line_diff(&before, &after); assert_eq!(hunks.len(), 1); - assert_eq!(hunks[0].before, before); - assert_eq!(hunks[0].after, after); - assert_eq!(hunks[0].before_start, 1); - assert_eq!(hunks[0].after_start, 1); + assert_eq!(hunks[0].before, vec!["b".to_string()]); + assert_eq!(hunks[0].after, vec!["c".to_string()]); + assert_eq!(hunks[0].before_start, 2); + assert_eq!(hunks[0].after_start, 2); + } + + #[test] + fn compute_line_diff_for_inserts_only_returns_addition_hunk() { + let before = vec!["a".to_string(), "b".to_string()]; + let after = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let hunks = compute_line_diff(&before, &after); + assert_eq!(hunks.len(), 1); + assert!(hunks[0].before.is_empty()); + assert_eq!(hunks[0].after, vec!["c".to_string()]); + assert_eq!(hunks[0].after_start, 3); + assert_eq!(hunks[0].before_start, 3); + } + + #[test] + fn compute_line_diff_for_deletes_only_returns_removal_hunk() { + let before = vec!["a".to_string(), "b".to_string(), "c".to_string()]; + let after = vec!["a".to_string(), "c".to_string()]; + let hunks = compute_line_diff(&before, &after); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].before, vec!["b".to_string()]); + assert!(hunks[0].after.is_empty()); + assert_eq!(hunks[0].before_start, 2); + assert_eq!(hunks[0].after_start, 2); + } + + #[test] + fn compute_line_diff_for_separate_changes_produces_multiple_hunks() { + let before = vec![ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + "e".to_string(), + ]; + let after = vec![ + "X".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + "Y".to_string(), + ]; + let hunks = compute_line_diff(&before, &after); + assert!( + hunks.len() >= 2, + "expected at least 2 hunks for two separate changes: {hunks:?}" + ); + // First hunk: a -> X at line 1 + assert_eq!(hunks.first().unwrap().before_start, 1); + // Last hunk: e -> Y at line 5 + assert_eq!(hunks.last().unwrap().before_start, 5); } #[test] diff --git a/tasks/lessons.md b/tasks/lessons.md index 0d0edc2..8ec1a61 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -44,3 +44,10 @@ - Mistake: section 構造が必要であることを起票時に意識せず、`gwt-build-spec` の completion gate で tasks セクション更新ができなくなった。 - Rule: SPEC を起票するときは body 内に `## Spec` / `## Plan` / `## Tasks` / `## TDD` の見出しを揃え、必要なら `gwtd issue spec --edit
` を初回投入時から使う。`--edit` 経由なら gwtd 側が `` コメントを管理してくれる。 - Checkpoint: 1. spec create 直後に `gwtd issue spec ` を実行して `` が埋まっているか確認 2. 空ならその場で `--edit spec` / `--edit plan` / `--edit tasks` / `--edit tdd` で投入し直す + +### 2026-05-12 (子 SPEC 増殖の防止) + +- Context: Phase 4 umbrella SPEC #191 を起票した直後、最優先サブタスクを「子 SPEC #192」として切り出してしまい、user から「基本的には子 SPEC は作らないでください」と明示指示を受けた。 +- Mistake: ralph loop / autonomous 進行時に、umbrella SPEC があってもサブタスクごとに新 SPEC を生やす癖が出た。Phase 1-3 の「Phase ごとに 1 SPEC」パターンを引きずって過剰に細分化した。 +- Rule: umbrella SPEC が存在する Phase では、サブタスクで独立した SPEC を新設しない。実装は umbrella SPEC を `Refs` する commit / PR で進め、umbrella SPEC 本文の Tasks セクションをチェックボックスで更新する。新 SPEC を起こすのは umbrella の意図と明確に外れる別軸の作業に限定する。 +- Checkpoint: 1. 新規 SPEC 起票前に「既存の umbrella SPEC で受け止められないか」を 1 度自問する 2. 起票する場合は umbrella との関係(吸収 / 並列 / 独立)を本文の `Related` で明示する