Skip to content

Commit fcfe977

Browse files
haasonsaasclaude
andcommitted
fix: 4 TDD-discovered bugs — comment detection, function boundaries, dedup severity, empty LLM responses
Bug 1: `is_comment_line` missed bare `#` (shell/Makefile/Python comments) - Changed `#!` → `#![` in non-comment prefixes (Rust inner attrs always have `[`) - Any `#` line not matching a known code prefix is now treated as a comment Bug 2: `find_function_end` ignored single-quoted strings - `'{'` in JS/Python strings was counted as a real brace, breaking function boundary detection. Now tracks both `"` and `'` string delimiters. Bug 3: `deduplicate_comments` could drop higher-severity comments - `dedup_by` keeps the first element; sort didn't include severity. Now sorts by severity (highest first) within same file/line/content group. Bug 4: OpenAI/Anthropic silently returned empty string for empty responses - Empty `choices`/`content` arrays now return errors instead of succeeding with empty content, preventing silent failures in the review pipeline. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c36ce89 commit fcfe977

File tree

5 files changed

+142
-33
lines changed

5 files changed

+142
-33
lines changed

src/adapters/anthropic.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -121,14 +121,13 @@ impl LLMAdapter for AnthropicAdapter {
121121
.content
122122
.first()
123123
.map(|c| {
124-
// Verify it's a text content type
125124
if c.content_type == "text" {
126-
c.text.clone()
125+
Ok(c.text.clone())
127126
} else {
128-
format!("Unsupported content type: {}", c.content_type)
127+
Err(anyhow::anyhow!("Unsupported content type: {}", c.content_type))
129128
}
130129
})
131-
.unwrap_or_default();
130+
.ok_or_else(|| anyhow::anyhow!("Anthropic returned empty content array — no content generated"))??;
132131

133132
Ok(LLMResponse {
134133
content,
@@ -292,7 +291,7 @@ mod tests {
292291
}
293292

294293
#[tokio::test]
295-
async fn test_unsupported_content_type() {
294+
async fn test_unsupported_content_type_returns_error() {
296295
let mut server = mockito::Server::new_async().await;
297296
let _mock = server
298297
.mock("POST", "/messages")
@@ -311,17 +310,17 @@ mod tests {
311310
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
312311
let result = adapter.complete(test_request()).await;
313312

314-
assert!(result.is_ok());
315-
let response = result.unwrap();
313+
assert!(result.is_err(), "Unsupported content type should return an error");
314+
let err = result.unwrap_err().to_string();
316315
assert!(
317-
response.content.contains("Unsupported content type"),
318-
"Expected 'Unsupported content type' in response, got: {}",
319-
response.content
316+
err.contains("Unsupported content type"),
317+
"Error should mention unsupported type, got: {}",
318+
err
320319
);
321320
}
322321

323322
#[tokio::test]
324-
async fn test_empty_content_array() {
323+
async fn test_empty_content_array_returns_error() {
325324
let mut server = mockito::Server::new_async().await;
326325
let _mock = server
327326
.mock("POST", "/messages")
@@ -340,9 +339,9 @@ mod tests {
340339
let adapter = AnthropicAdapter::new(test_config(&server.url())).unwrap();
341340
let result = adapter.complete(test_request()).await;
342341

343-
assert!(result.is_ok());
344-
let response = result.unwrap();
345-
assert_eq!(response.content, "");
342+
assert!(result.is_err(), "Empty content array should return an error, not silently succeed");
343+
let err = result.unwrap_err().to_string();
344+
assert!(err.contains("empty content"), "Error should mention empty content: {}", err);
346345
}
347346

348347
#[test]

src/adapters/openai.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,7 @@ impl OpenAIAdapter {
196196
.choices
197197
.first()
198198
.map(|c| c.message.content.clone())
199-
.unwrap_or_default();
199+
.ok_or_else(|| anyhow::anyhow!("OpenAI returned empty choices array — no content generated"))?;
200200

201201
Ok(LLMResponse {
202202
content,
@@ -416,7 +416,7 @@ mod tests {
416416
}
417417

418418
#[tokio::test]
419-
async fn test_empty_choices_returns_empty_content() {
419+
async fn test_empty_choices_returns_error() {
420420
let mut server = mockito::Server::new_async().await;
421421
let _mock = server
422422
.mock("POST", "/chat/completions")
@@ -435,9 +435,9 @@ mod tests {
435435
let adapter = OpenAIAdapter::new(test_config(&server.url())).unwrap();
436436
let result = adapter.complete(test_request()).await;
437437

438-
assert!(result.is_ok());
439-
let response = result.unwrap();
440-
assert_eq!(response.content, "");
438+
assert!(result.is_err(), "Empty choices should return an error, not silently succeed");
439+
let err = result.unwrap_err().to_string();
440+
assert!(err.contains("empty choices"), "Error should mention empty choices: {}", err);
441441
}
442442

443443
#[test]

src/core/comment.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -449,12 +449,23 @@ impl CommentSynthesizer {
449449
}
450450

451451
fn deduplicate_comments(comments: &mut Vec<Comment>) {
452+
let severity_rank = |s: &Severity| match s {
453+
Severity::Error => 0,
454+
Severity::Warning => 1,
455+
Severity::Info => 2,
456+
Severity::Suggestion => 3,
457+
};
458+
459+
// Sort by file/line/content, then by severity (highest first)
452460
comments.sort_by(|a, b| {
453461
a.file_path
454462
.cmp(&b.file_path)
455463
.then(a.line_number.cmp(&b.line_number))
456464
.then(a.content.cmp(&b.content))
465+
.then(severity_rank(&a.severity).cmp(&severity_rank(&b.severity)))
457466
});
467+
// dedup_by keeps the first element (b) of consecutive duplicates,
468+
// which is the highest severity due to our sort order
458469
comments.dedup_by(|a, b| {
459470
a.file_path == b.file_path && a.line_number == b.line_number && a.content == b.content
460471
});
@@ -548,6 +559,49 @@ pub struct RawComment {
548559
mod tests {
549560
use super::*;
550561

562+
#[test]
563+
fn test_deduplicate_preserves_highest_severity() {
564+
// BUG: dedup_by keeps the first element of a consecutive pair,
565+
// but doesn't consider severity. If Warning comes before Error
566+
// (due to stable sort on file/line/content), the Error is dropped.
567+
let raw_comments = vec![
568+
RawComment {
569+
file_path: PathBuf::from("src/lib.rs"),
570+
line_number: 10,
571+
content: "Missing null check".to_string(),
572+
rule_id: None,
573+
suggestion: None,
574+
severity: Some(Severity::Warning),
575+
category: Some(Category::Bug),
576+
confidence: Some(0.8),
577+
fix_effort: None,
578+
tags: Vec::new(),
579+
code_suggestion: None,
580+
},
581+
RawComment {
582+
file_path: PathBuf::from("src/lib.rs"),
583+
line_number: 10,
584+
content: "Missing null check".to_string(),
585+
rule_id: None,
586+
suggestion: None,
587+
severity: Some(Severity::Error),
588+
category: Some(Category::Bug),
589+
confidence: Some(0.9),
590+
fix_effort: None,
591+
tags: Vec::new(),
592+
code_suggestion: None,
593+
},
594+
];
595+
596+
let comments = CommentSynthesizer::synthesize(raw_comments).unwrap();
597+
assert_eq!(comments.len(), 1, "Should deduplicate to one comment");
598+
assert_eq!(
599+
comments[0].severity,
600+
Severity::Error,
601+
"Should keep the higher severity (Error), not the lower (Warning)"
602+
);
603+
}
604+
551605
#[test]
552606
fn test_severity_display() {
553607
assert_eq!(Severity::Error.to_string(), "Error");

src/core/function_chunker.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -255,16 +255,19 @@ fn find_function_end(lines: &[&str], start: usize, language: &str) -> usize {
255255
let mut depth = 0i32;
256256
let mut found_open = false;
257257
for (i, line) in lines.iter().enumerate().skip(start) {
258-
let mut in_string = false;
258+
let mut in_double_quote = false;
259+
let mut in_single_quote = false;
259260
let mut escaped = false;
260261
for ch in line.chars() {
261262
if escaped {
262263
escaped = false;
263-
} else if in_string && ch == '\\' {
264+
} else if (in_double_quote || in_single_quote) && ch == '\\' {
264265
escaped = true;
265-
} else if ch == '"' {
266-
in_string = !in_string;
267-
} else if !in_string {
266+
} else if ch == '"' && !in_single_quote {
267+
in_double_quote = !in_double_quote;
268+
} else if ch == '\'' && !in_double_quote {
269+
in_single_quote = !in_single_quote;
270+
} else if !in_double_quote && !in_single_quote {
268271
if ch == '{' {
269272
depth += 1;
270273
found_open = true;
@@ -911,6 +914,26 @@ fn next_func() {
911914
assert_eq!(result, Some(2));
912915
}
913916

917+
#[test]
918+
fn test_find_function_end_single_quoted_string_with_brace() {
919+
// BUG: single-quoted strings aren't tracked, so `'{'` is counted as a real brace
920+
let lines: Vec<&str> = vec![
921+
"function foo() {", // line 0: depth = 1
922+
" let s = '{';", // line 1: depth should stay 1, but goes to 2
923+
" console.log(s);", // line 2
924+
"}", // line 3: depth goes to 1 (not 0!), so foo "never ends"
925+
"", // line 4
926+
"function bar() {", // line 5: depth goes to 2
927+
" return 1;", // line 6
928+
"}", // line 7: depth goes to 1, and foo is "found" here (wrong!)
929+
];
930+
let end = find_function_end(&lines, 0, "js");
931+
assert_eq!(
932+
end, 3,
933+
"foo() should end at line 3, not bleed into bar() due to untracked single-quoted brace"
934+
);
935+
}
936+
914937
#[test]
915938
fn test_find_function_end_escaped_backslash_in_string() {
916939
// A string containing "\\" (escaped backslash) on the same line as braces.

src/review/triage.rs

Lines changed: 42 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const COMMENT_PREFIXES: &[&str] = &["//", "# ", "/*", "*/", "* ", "--", "<!--",
4545

4646
/// Patterns that start with `#` but are NOT comments (Rust attributes, C preprocessor, etc.).
4747
const HASH_NON_COMMENT_PREFIXES: &[&str] = &[
48-
"#[", "#!", "#include", "#define", "#ifdef", "#ifndef", "#endif", "#pragma", "#undef", "#elif",
48+
"#[", "#![", "#include", "#define", "#ifdef", "#ifndef", "#endif", "#pragma", "#undef", "#elif",
4949
"#else", "#if ", "#error", "#warning", "#line",
5050
];
5151

@@ -179,13 +179,18 @@ fn is_comment_line(content: &str) -> bool {
179179
// Blank lines in a comment-only change are fine
180180
return true;
181181
}
182-
// Handle `#` lines: exclude Rust attributes, C preprocessor, etc.
183-
if trimmed.starts_with('#')
184-
&& HASH_NON_COMMENT_PREFIXES
182+
// Handle `#` lines: treat as comment UNLESS it matches a known non-comment prefix
183+
// (Rust attributes, C preprocessor, etc.)
184+
if trimmed.starts_with('#') {
185+
// If it matches a non-comment prefix like #[, #include, etc., it's code
186+
if HASH_NON_COMMENT_PREFIXES
185187
.iter()
186188
.any(|p| trimmed.starts_with(p))
187-
{
188-
return false;
189+
{
190+
return false;
191+
}
192+
// Otherwise bare `#`, `#comment`, `# comment` are all comments
193+
return true;
189194
}
190195
COMMENT_PREFIXES
191196
.iter()
@@ -888,13 +893,14 @@ mod tests {
888893
}
889894

890895
#[test]
891-
fn test_comment_hash_without_space_is_not_comment() {
892-
// "#tag" — not a comment prefix since we changed to "# "
896+
fn test_comment_hash_without_space_is_comment() {
897+
// "#tag" IS a valid comment in Python/shell/Ruby/Makefile
898+
// Only known code patterns (#[, #include, etc.) are excluded
893899
let diff = make_diff(
894900
"src/lib.rs",
895901
vec![make_line(1, ChangeType::Added, "#tag: important")],
896902
);
897-
assert_eq!(triage_diff(&diff), TriageResult::NeedsReview);
903+
assert_eq!(triage_diff(&diff), TriageResult::SkipCommentOnly);
898904
}
899905

900906
#[test]
@@ -950,6 +956,33 @@ mod tests {
950956
assert_eq!(triage_diff(&diff), TriageResult::NeedsReview);
951957
}
952958

959+
#[test]
960+
fn test_comment_bare_hash_is_comment() {
961+
// Bare `#` without trailing space is a valid comment in shell, Makefile, Python
962+
let diff = make_diff(
963+
"deploy.sh",
964+
vec![
965+
make_line(1, ChangeType::Added, "#"),
966+
make_line(2, ChangeType::Added, "#!"),
967+
],
968+
);
969+
// BUG: currently returns NeedsReview because `#` doesn't match `"# "` prefix
970+
assert_eq!(triage_diff(&diff), TriageResult::SkipCommentOnly);
971+
}
972+
973+
#[test]
974+
fn test_comment_hash_with_no_space_in_makefile() {
975+
// Makefile comments use `#` without requiring a space
976+
let diff = make_diff(
977+
"Makefile",
978+
vec![
979+
make_line(1, ChangeType::Removed, "#old target comment"),
980+
make_line(1, ChangeType::Added, "#new target comment"),
981+
],
982+
);
983+
assert_eq!(triage_diff(&diff), TriageResult::SkipCommentOnly);
984+
}
985+
953986
#[test]
954987
fn test_all_blank_lines_is_comment_only() {
955988
// Blank lines count as "comment" in is_comment_line

0 commit comments

Comments
 (0)