diff --git a/Cargo.lock b/Cargo.lock index 3edd816..dee90a9 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 4dfb359..b0b791c 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` で明示する