From ac9e470081e39165bc54db6016f216f13beb111c Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Thu, 9 Jul 2026 19:41:10 +0200 Subject: [PATCH] diff: Preserve left/right anchor paths Track old and new diff header paths separately so LEFT anchors keep the pre-image path while RIGHT and context anchors keep the post-image path. This fixes rename and delete hunks where findings must resolve against different repository locations. While parsing hunks, only recognize file headers in the outer scan. Header-like payload lines inside a hunk body must remain part of the hunk or later finding attachment can truncate and misanchor. Add regression coverage for rename edits, deleted-file anchors, header-like payload lines, and end-to-end attachment resolution. Signed-off-by: Minh Vu --- README.md | 2 - src/api.rs | 220 +++++++++++++++++++++++++++++++++---- src/diff_index.rs | 271 +++++++++++++++++++++++++++++++--------------- src/main.rs | 80 ++++++++++++++ 4 files changed, 463 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index d2064f8..6271533 100644 --- a/README.md +++ b/README.md @@ -213,8 +213,6 @@ backends via `--backend claude|opencode|codex`. In this mode, boro relies on the agent CLI for credentials, tooling, and permissions. -The Codex backend uses `codex exec --json` with approval prompts disabled -(`--ask-for-approval never` and `--dangerously-bypass-approvals-and-sandbox`). ## Use with local Ollama diff --git a/src/api.rs b/src/api.rs index ff6e38d..b2e1732 100644 --- a/src/api.rs +++ b/src/api.rs @@ -4257,7 +4257,11 @@ mention each finding with severity, keep a professional tone, no markdown headin #[derive(Debug, Clone)] pub struct DiffHunk { /// New-file path (`+++ b/` minus the `b/` prefix when present). + /// This is also used as the primary path for context lines and RIGHT-side anchoring. pub file: String, + /// Left-file path (`--- a/` minus the `a/` prefix when present). + /// LEFT-side anchoring uses this when present. + pub old_file: Option, /// Header line, e.g. `@@ -100,7 +100,7 @@ static void foo(...)`. pub header: String, /// Inclusive 1-based old-file start and length (0 length = pure insertion). @@ -4276,44 +4280,56 @@ pub struct DiffHunk { /// body lines were captured. pub fn collect_diff_hunks(patch: &str) -> Vec { let mut hunks: Vec = Vec::new(); - let mut current_file: Option = None; + let mut current_old_file: Option = None; + let mut current_new_file: Option = None; let mut iter = patch.lines().peekable(); while let Some(line) = iter.next() { + if line.starts_with("diff --git ") { + current_old_file = None; + current_new_file = None; + continue; + } if let Some(rest) = line.strip_prefix("+++ ") { - // `+++ b/path/to/file` or `+++ path/to/file` or `+++ /dev/null` - let path = rest.split_whitespace().next().unwrap_or(""); - let path = path.strip_prefix("b/").unwrap_or(path); - current_file = if path == "/dev/null" { - None - } else { - Some(path.to_string()) - }; + current_new_file = parse_diff_path(rest); + continue; + } + if let Some(rest) = line.strip_prefix("--- ") { + current_old_file = parse_diff_path(rest); continue; } if let Some(parsed) = parse_hunk_header(line) { - let Some(file) = current_file.clone() else { + let Some(file) = current_new_file.clone().or(current_old_file.clone()) else { continue; }; let (old_start, old_len, new_start, new_len) = parsed; let mut text = String::from(line); text.push('\n'); - // Consume hunk body until the next non-body line (peek so we don't swallow it). + let mut old_remaining = old_len; + let mut new_remaining = new_len; while let Some(peek) = iter.peek() { - let first = peek.as_bytes().first().copied(); - let is_body = matches!(first, Some(b' ') | Some(b'+') | Some(b'-') | Some(b'\\')); - if !is_body { - break; - } - // A `+++ ` or `--- ` file header starts with `+`/`-` too - stop in that case. - if peek.starts_with("+++ ") || peek.starts_with("--- ") { + let Some(kind) = classify_hunk_body_line(peek, old_remaining, new_remaining) else { break; - } + }; let body = iter.next().unwrap(); text.push_str(body); text.push('\n'); + match kind { + HunkBodyLine::Addition => { + new_remaining -= 1; + } + HunkBodyLine::Deletion => { + old_remaining -= 1; + } + HunkBodyLine::Context => { + old_remaining -= 1; + new_remaining -= 1; + } + HunkBodyLine::NoNewline => {} + } } hunks.push(DiffHunk { file, + old_file: current_old_file.clone(), header: line.to_string(), old_start, old_len, @@ -4326,6 +4342,29 @@ pub fn collect_diff_hunks(patch: &str) -> Vec { hunks } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HunkBodyLine { + Addition, + Deletion, + Context, + NoNewline, +} + +fn classify_hunk_body_line( + line: &str, + old_remaining: u32, + new_remaining: u32, +) -> Option { + match line.as_bytes().first().copied() { + Some(b'+') if new_remaining > 0 => Some(HunkBodyLine::Addition), + Some(b'-') if old_remaining > 0 => Some(HunkBodyLine::Deletion), + Some(b' ') if old_remaining > 0 && new_remaining > 0 => Some(HunkBodyLine::Context), + None if old_remaining > 0 && new_remaining > 0 => Some(HunkBodyLine::Context), + Some(b'\\') => Some(HunkBodyLine::NoNewline), + _ => None, + } +} + /// Parse `@@ -A[,B] +C[,D] @@ ...` into `(A, B, C, D)`. B and D default to 1 when omitted. fn parse_hunk_header(line: &str) -> Option<(u32, u32, u32, u32)> { let rest = line.strip_prefix("@@ ")?; @@ -4349,6 +4388,19 @@ fn parse_range(s: &str) -> Option<(u32, u32)> { Some((start, len)) } +fn parse_diff_path(rest: &str) -> Option { + let path = rest.split_whitespace().next().unwrap_or(""); + let path = path + .strip_prefix("a/") + .or_else(|| path.strip_prefix("b/")) + .unwrap_or(path); + if path.is_empty() || path == "/dev/null" { + None + } else { + Some(path.to_string()) + } +} + /// Find the hunk that owns `(file, line)` on `side` (`"LEFT"` = old file, anything else = new /// file). Empty ranges (pure insertions / deletions) match the line where they were inserted. pub fn find_hunk_for_location<'a>( @@ -4359,7 +4411,12 @@ pub fn find_hunk_for_location<'a>( ) -> Option<&'a DiffHunk> { let want_left = side.eq_ignore_ascii_case("LEFT"); hunks.iter().find(|h| { - if h.file != file { + let hunk_file = if want_left { + h.old_file.as_deref().unwrap_or(&h.file) + } else { + &h.file + }; + if hunk_file != file { return false; } let (start, len) = if want_left { @@ -5769,11 +5826,66 @@ index 111..222 100644 \treturn; "; + const RENAME_PATCH: &str = "\ +diff --git a/app/main.rs b/app/main_renamed.rs +similarity index 89% +rename from app/main.rs +rename to app/main_renamed.rs +--- a/app/main.rs ++++ b/app/main_renamed.rs +@@ -10,4 +10,4 @@ main flow + \ttrace!(\"before\"); +-\tprintln!(\"old behavior\"); ++\tprintln!(\"new behavior\"); + \ttrace!(\"after\"); +"; + + const DELETE_PATCH: &str = "\ +diff --git a/app/removed.rs b/app/removed.rs +deleted file mode 100644 +index 8f3..000 +--- a/app/removed.rs ++++ /dev/null +@@ -5,2 +0,0 @@ +-\tconsole!(\"remove me\"); +-\tcleanup(); +"; + + const HEADER_LIKE_BODY_PATCH: &str = "\ +diff --git a/scripts/example.txt b/scripts/example.txt +index 123..456 100644 +--- a/scripts/example.txt ++++ b/scripts/example.txt +@@ -1,3 +1,4 @@ + alpha +--- looks like a header but is body ++++ still body, not a header ++tail line that must stay in the hunk + omega +@@ -10,1 +11,1 @@ +-old second line ++new second line +"; + + const MULTI_FILE_UNIFIED_PATCH: &str = "\ +--- a/foo.c ++++ b/foo.c +@@ -1,1 +1,1 @@ +-old ++new +--- a/bar.c ++++ b/bar.c +@@ -10,1 +10,1 @@ +-old_bar ++new_bar +"; + #[test] fn collect_diff_hunks_parses_two_files() { let hunks = collect_diff_hunks(SAMPLE_PATCH); assert_eq!(hunks.len(), 2); assert_eq!(hunks[0].file, "foo.c"); + assert_eq!(hunks[0].old_file.as_deref(), Some("foo.c")); assert_eq!(hunks[0].old_start, 100); assert_eq!(hunks[0].new_start, 100); assert_eq!(hunks[0].old_len, 5); @@ -5781,6 +5893,7 @@ index 111..222 100644 assert!(hunks[0].text.starts_with("@@ -100,5 +100,5 @@")); assert!(hunks[0].text.contains("+\tint new = 0;")); assert_eq!(hunks[1].file, "bar.c"); + assert_eq!(hunks[1].old_file.as_deref(), Some("bar.c")); assert_eq!(hunks[1].new_start, 10); } @@ -5799,6 +5912,55 @@ index 111..222 100644 assert!(find_hunk_for_location(&hunks, "foo.c", 200, "RIGHT").is_none()); } + #[test] + fn collect_diff_hunks_tracks_pre_and_post_paths_for_rename() { + let hunks = collect_diff_hunks(RENAME_PATCH); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].file, "app/main_renamed.rs"); + assert_eq!(hunks[0].old_file.as_deref(), Some("app/main.rs")); + let left = find_hunk_for_location(&hunks, "app/main.rs", 10, "LEFT").unwrap(); + assert!(left.header.contains("-10,4")); + let right = find_hunk_for_location(&hunks, "app/main_renamed.rs", 10, "RIGHT").unwrap(); + assert!(right.header.contains("+10,4")); + } + + #[test] + fn collect_diff_hunks_tracks_deleted_file_left_path() { + let hunks = collect_diff_hunks(DELETE_PATCH); + assert_eq!(hunks.len(), 1); + assert_eq!(hunks[0].file, "app/removed.rs"); + assert_eq!(hunks[0].old_file.as_deref(), Some("app/removed.rs")); + let left = find_hunk_for_location(&hunks, "app/removed.rs", 5, "LEFT").unwrap(); + assert!(left.header.contains("-5,2")); + assert!(find_hunk_for_location(&hunks, "app/removed.rs", 5, "RIGHT").is_none()); + } + + #[test] + fn collect_diff_hunks_keeps_header_like_body_lines_inside_hunk_text() { + let hunks = collect_diff_hunks(HEADER_LIKE_BODY_PATCH); + assert_eq!(hunks.len(), 2); + assert_eq!(hunks[0].file, "scripts/example.txt"); + assert_eq!(hunks[1].file, "scripts/example.txt"); + assert!(hunks[0] + .text + .contains("--- looks like a header but is body")); + assert!(hunks[0].text.contains("+++ still body, not a header")); + assert!(hunks[0] + .text + .contains("+tail line that must stay in the hunk")); + assert!(hunks[1].text.contains("+new second line")); + } + + #[test] + fn collect_diff_hunks_respects_declared_ranges_without_diff_git_headers() { + let hunks = collect_diff_hunks(MULTI_FILE_UNIFIED_PATCH); + assert_eq!(hunks.len(), 2); + assert_eq!(hunks[0].file, "foo.c"); + assert_eq!(hunks[1].file, "bar.c"); + assert!(!hunks[0].text.contains("--- a/bar.c")); + assert!(hunks[1].text.contains("+new_bar")); + } + #[test] fn collect_finding_hunks_dedups_and_skips_missing() { let findings = json!({"findings":[ @@ -5826,6 +5988,24 @@ index 111..222 100644 assert_eq!(atts[1].hunk.file, "bar.c"); } + #[test] + fn collect_finding_hunks_keeps_later_file_context_after_header_like_body_lines() { + let findings = json!({"findings":[ + {"problem":"x","severity":"Low","severity_explanation":"y", + "location":{"file":"scripts/example.txt","line":11,"side":"RIGHT"}}, + ]}); + let atts = collect_finding_hunks(&findings, HEADER_LIKE_BODY_PATCH); + assert_eq!(atts.len(), 1); + assert_eq!(atts[0].finding_indices, vec![1]); + assert_eq!(atts[0].hunk.file, "scripts/example.txt"); + assert_eq!( + atts[0].hunk.old_file.as_deref(), + Some("scripts/example.txt") + ); + assert!(atts[0].hunk.header.contains("+11,1")); + assert!(atts[0].hunk.text.contains("+new second line")); + } + #[test] fn lkml_payload_appends_hunks_section_and_verbatim_directive() { let findings = json!({"findings":[ diff --git a/src/diff_index.rs b/src/diff_index.rs index 11d6e90..0f0175d 100644 --- a/src/diff_index.rs +++ b/src/diff_index.rs @@ -43,77 +43,73 @@ impl DiffIndex { /// Parse a unified diff (output of `git show --no-color` or `git diff`) and index every /// hunk line. Robust against: /// - extended header lines (`diff --git`, `index ...`, `similarity index ...`, etc.) - /// - `--- a/` / `+++ b/` path lines (the `b/` path wins, since findings - /// reference the post-image file) - /// - new files (`+++ /dev/null` is ignored; only the live side gets indexed) - /// - multiple files in one diff - /// - lines that do not match `+`, `-`, ` ` (treated as out-of-hunk and ignored) + /// - separate `--- old/path` / `+++ new/path` names for renames and deleted files + /// - header-like payload lines (`--- body`, `+++ body`) by consuming the exact `@@` ranges + /// - new files or deleted files where only one side has a live path + /// - multiple files in one diff, including traditional unified diffs without `diff --git` pub fn from_unified_diff(diff: &str) -> Self { let mut idx = Self::new(); - let mut path: Option = None; - let mut old_line: u64 = 0; - let mut new_line: u64 = 0; - let mut in_hunk = false; + let mut old_path: Option = None; + let mut new_path: Option = None; + let mut iter = diff.lines().peekable(); - for line in diff.lines() { - if let Some(rest) = line.strip_prefix("+++ ") { - // +++ b/path OR +++ /dev/null - path = parse_diff_path(rest); - in_hunk = false; - continue; - } - if line.starts_with("--- ") { - // Ignore - +++ wins. Reset hunk state. - in_hunk = false; - continue; - } + while let Some(line) = iter.next() { if line.starts_with("diff --git ") { - // New file boundary. Path will be set by the upcoming `+++` line. - path = None; - in_hunk = false; + old_path = None; + new_path = None; continue; } - if let Some(rest) = line.strip_prefix("@@ ") { - if let Some((o, n)) = parse_hunk_header(rest) { - old_line = o; - new_line = n; - in_hunk = true; - } else { - in_hunk = false; - } + if let Some(rest) = line.strip_prefix("--- ") { + old_path = parse_diff_path(rest); continue; } - if !in_hunk { + if let Some(rest) = line.strip_prefix("+++ ") { + new_path = parse_diff_path(rest); continue; } - let Some(path_str) = path.as_deref() else { + let Some((mut old_line, mut old_remaining, mut new_line, mut new_remaining)) = + parse_hunk_header(line) + else { continue; }; - let first = line.chars().next(); - match first { - Some('+') => { - idx.insert(path_str, new_line, Side::Right, &line[1..]); - new_line += 1; - } - Some('-') => { - idx.insert(path_str, old_line, Side::Left, &line[1..]); - old_line += 1; - } - Some(' ') | None => { - // Context (a leading space) or a bare empty line, which `git diff` emits for - // an entirely blank context line. Index under both sides. - let text = line.strip_prefix(' ').unwrap_or(line); - idx.insert(path_str, new_line, Side::Right, text); - idx.insert(path_str, old_line, Side::Left, text); - old_line += 1; - new_line += 1; - } - Some('\\') => { - // "\ No newline at end of file" - no counter update. - } - _ => { - // Anything else terminates the current hunk; wait for the next `@@`. - in_hunk = false; + + while let Some(peek) = iter.peek() { + let Some(kind) = classify_hunk_body_line(peek, old_remaining, new_remaining) else { + break; + }; + let body = iter.next().unwrap(); + match kind { + HunkBodyLine::Addition => { + if let Some(path) = new_path.as_deref() { + idx.insert(path, new_line, Side::Right, &body[1..]); + } + new_line += 1; + new_remaining -= 1; + } + HunkBodyLine::Deletion => { + if let Some(path) = old_path.as_deref() { + idx.insert(path, old_line, Side::Left, &body[1..]); + } + old_line += 1; + old_remaining -= 1; + } + HunkBodyLine::Context => { + let text = body.strip_prefix(' ').unwrap_or(body); + if let Some(path) = new_path.as_deref() { + idx.insert(path, new_line, Side::Right, text); + } + if let Some(path) = old_path.as_deref() { + idx.insert(path, old_line, Side::Left, text); + } + old_line += 1; + new_line += 1; + old_remaining -= 1; + new_remaining -= 1; + } + HunkBodyLine::NoNewline => { + // "\ No newline at end of file" belongs to the current hunk but does + // not consume an old/new line number. + } } } } @@ -157,27 +153,61 @@ fn contains_c_identifier(text: &str, ident: &str) -> bool { }) } -/// Parse a unified-diff hunk header tail after `@@ `: `-OLD[,OCNT] +NEW[,NCNT] @@ context`. -/// Returns `(old_start, new_start)` or `None` if the header is malformed. -fn parse_hunk_header(rest: &str) -> Option<(u64, u64)> { - // Format: "-O[,C] +N[,C] @@ ..." - let mut parts = rest.split_whitespace(); - let old_tok = parts.next()?; - let new_tok = parts.next()?; - let old_num = old_tok.strip_prefix('-')?.split(',').next()?; - let new_num = new_tok.strip_prefix('+')?.split(',').next()?; - Some((old_num.parse().ok()?, new_num.parse().ok()?)) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HunkBodyLine { + Addition, + Deletion, + Context, + NoNewline, } -/// Extract the post-image path from a `+++ b/` line. Returns `None` for `/dev/null` -/// (file deleted) so we don't index against a sentinel name. Tolerates the absence of the -/// `b/` prefix (some diff producers omit it). +fn classify_hunk_body_line( + line: &str, + old_remaining: u64, + new_remaining: u64, +) -> Option { + match line.as_bytes().first().copied() { + Some(b'+') if new_remaining > 0 => Some(HunkBodyLine::Addition), + Some(b'-') if old_remaining > 0 => Some(HunkBodyLine::Deletion), + Some(b' ') if old_remaining > 0 && new_remaining > 0 => Some(HunkBodyLine::Context), + None if old_remaining > 0 && new_remaining > 0 => Some(HunkBodyLine::Context), + Some(b'\\') => Some(HunkBodyLine::NoNewline), + _ => None, + } +} + +/// Parse `@@ -A[,B] +C[,D] @@ ...` into `(A, B, C, D)`. B and D default to 1 when omitted. +fn parse_hunk_header(line: &str) -> Option<(u64, u64, u64, u64)> { + let rest = line.strip_prefix("@@ ")?; + let mut parts = rest.splitn(3, ' '); + let old = parts.next()?.strip_prefix('-')?; + let new = parts.next()?.strip_prefix('+')?; + parts.next()?; + let (old_start, old_len) = parse_range(old)?; + let (new_start, new_len) = parse_range(new)?; + Some((old_start, old_len, new_start, new_len)) +} + +fn parse_range(s: &str) -> Option<(u64, u64)> { + let mut it = s.splitn(2, ','); + let start = it.next()?.parse().ok()?; + let len = match it.next() { + Some(n) => n.parse().ok()?, + None => 1, + }; + Some((start, len)) +} + +/// Extract the live path from a `---` or `+++` header. Returns `None` for `/dev/null`. fn parse_diff_path(rest: &str) -> Option { let raw = rest.trim().split('\t').next()?.trim(); if raw == "/dev/null" { return None; } - let cleaned = raw.strip_prefix("b/").unwrap_or(raw); + let cleaned = raw + .strip_prefix("a/") + .or_else(|| raw.strip_prefix("b/")) + .unwrap_or(raw); if cleaned.is_empty() { None } else { @@ -204,6 +234,60 @@ index 1111111..2222222 100644 ctx4 "; + const RENAME_DIFF: &str = "\ +diff --git a/app/main.rs b/app/main_renamed.rs +similarity index 89% +rename from app/main.rs +rename to app/main_renamed.rs +--- a/app/main.rs ++++ b/app/main_renamed.rs +@@ -10,4 +10,4 @@ main flow + trace!(\"before\"); +-println!(\"old behavior\"); ++println!(\"new behavior\"); + trace!(\"after\"); +"; + + const DELETE_DIFF: &str = "\ +diff --git a/app/removed.rs b/app/removed.rs +deleted file mode 100644 +index 8f3..000 +--- a/app/removed.rs ++++ /dev/null +@@ -5,2 +0,0 @@ +-console!(\"remove me\"); +-cleanup(); +"; + + const HEADER_LIKE_BODY_DIFF: &str = "\ +diff --git a/scripts/example.txt b/scripts/example.txt +index 123..456 100644 +--- a/scripts/example.txt ++++ b/scripts/example.txt +@@ -1,3 +1,4 @@ + alpha +--- looks like a header but is body ++++ still body, not a header ++tail line that must stay in the hunk + omega +@@ -10,1 +11,1 @@ +-old second line ++new second line +"; + + const MULTI_FILE_UNIFIED_DIFF: &str = "\ +--- a/foo.c ++++ b/foo.c +@@ -1,1 +1,1 @@ +-old ++new +--- a/bar.c ++++ b/bar.c +@@ -10,1 +10,1 @@ +-old_bar ++new_bar +"; + #[test] fn indexes_added_lines_on_right() { let idx = DiffIndex::from_unified_diff(SIMPLE_DIFF); @@ -282,21 +366,32 @@ new file mode 100644 } #[test] - fn deleted_file_dev_null_right_drops_path() { - let diff = "\ -diff --git a/gone.c b/gone.c -deleted file mode 100644 ---- a/gone.c -+++ /dev/null -@@ -1,2 +0,0 @@ --line1 --line2 -"; - let idx = DiffIndex::from_unified_diff(diff); - // We don't index the deletion side (the post-image path is /dev/null). - // The viewer's b-side path defaulting means findings on a deleted file are - // commit-level anyway, so this is fine. - assert!(!idx.contains("gone.c", 1, Side::Left)); + fn rename_indexes_left_and_right_paths_separately() { + let idx = DiffIndex::from_unified_diff(RENAME_DIFF); + assert!(idx.contains("app/main.rs", 11, Side::Left)); + assert!(idx.contains("app/main_renamed.rs", 11, Side::Right)); + } + + #[test] + fn deleted_file_indexes_left_side_under_old_path() { + let idx = DiffIndex::from_unified_diff(DELETE_DIFF); + assert!(idx.contains("app/removed.rs", 5, Side::Left)); + assert!(!idx.contains("app/removed.rs", 5, Side::Right)); + } + + #[test] + fn header_like_hunk_body_lines_do_not_break_later_hunks() { + let idx = DiffIndex::from_unified_diff(HEADER_LIKE_BODY_DIFF); + assert!(idx.contains("scripts/example.txt", 11, Side::Right)); + assert!(idx.contains("scripts/example.txt", 10, Side::Left)); + } + + #[test] + fn declared_hunk_counts_keep_traditional_multifile_diffs_separate() { + let idx = DiffIndex::from_unified_diff(MULTI_FILE_UNIFIED_DIFF); + assert!(idx.contains("foo.c", 1, Side::Left)); + assert!(idx.contains("bar.c", 10, Side::Right)); + assert!(!idx.contains("bar.c", 1, Side::Right)); } #[test] diff --git a/src/main.rs b/src/main.rs index 522326b..4d7d4ae 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4329,6 +4329,47 @@ diff --git a/foo.c b/foo.c last "; + const RENAME_DIFF: &str = "\ +diff --git a/app/main.rs b/app/main_renamed.rs +similarity index 89% +rename from app/main.rs +rename to app/main_renamed.rs +--- a/app/main.rs ++++ b/app/main_renamed.rs +@@ -10,4 +10,4 @@ main flow + trace!(\"before\"); +-println!(\"old behavior\"); ++println!(\"new behavior\"); + trace!(\"after\"); +"; + + const DELETE_DIFF: &str = "\ +diff --git a/app/removed.rs b/app/removed.rs +deleted file mode 100644 +index 8f3..000 +--- a/app/removed.rs ++++ /dev/null +@@ -5,2 +0,0 @@ +-remove_me(); +-cleanup(); +"; + + const HEADER_LIKE_BODY_DIFF: &str = "\ +diff --git a/scripts/example.txt b/scripts/example.txt +index 123..456 100644 +--- a/scripts/example.txt ++++ b/scripts/example.txt +@@ -1,3 +1,4 @@ + alpha +--- looks like a header but is body ++++ still body, not a header ++tail line that must stay in the hunk + omega +@@ -10,1 +11,1 @@ +-old second line ++new second line +"; + fn vd() -> VerboseDest { VerboseDest::new(false) } @@ -4645,6 +4686,45 @@ diff --git a/foo.c b/foo.c assert!(findings["findings"][0].get("location").is_none()); } + #[test] + fn keeps_left_location_for_renamed_file_old_path() { + let idx = diff_index::DiffIndex::from_unified_diff(RENAME_DIFF); + let mut findings = json!({ + "findings": [ + {"problem": "x", "severity": "Low", "severity_explanation": "y", + "location": {"file": "app/main.rs", "line": 11, "side": "LEFT"}} + ] + }); + drop_unanchored_locations(&mut findings, &idx, &vd()); + assert!(findings["findings"][0].get("location").is_some()); + } + + #[test] + fn keeps_left_location_for_deleted_file() { + let idx = diff_index::DiffIndex::from_unified_diff(DELETE_DIFF); + let mut findings = json!({ + "findings": [ + {"problem": "x", "severity": "Low", "severity_explanation": "y", + "location": {"file": "app/removed.rs", "line": 5, "side": "LEFT"}} + ] + }); + drop_unanchored_locations(&mut findings, &idx, &vd()); + assert!(findings["findings"][0].get("location").is_some()); + } + + #[test] + fn keeps_later_hunk_after_header_like_body_lines() { + let idx = diff_index::DiffIndex::from_unified_diff(HEADER_LIKE_BODY_DIFF); + let mut findings = json!({ + "findings": [ + {"problem": "x", "severity": "Low", "severity_explanation": "y", + "location": {"file": "scripts/example.txt", "line": 11, "side": "RIGHT"}} + ] + }); + drop_unanchored_locations(&mut findings, &idx, &vd()); + assert!(findings["findings"][0].get("location").is_some()); + } + #[test] fn invalid_location_is_cleaned_before_message_typo_repair() { let patch = "\